Skip to main content

vector_core/community/v2/
service.rs

1//! Concord v2 service — the stateful orchestration binding the pure v2 modules
2//! to storage + transport. Free functions, `SessionGuard`-gated at every write
3//! (a `swap_session` can land at any await — see CLAUDE.md), mirroring the v1
4//! service's discipline.
5//!
6//! First-cut scope (bots): create a community, send + fetch channel messages,
7//! accept invites, publish a Guestbook Join. Rotation/refounding/moderation and
8//! full roster folding layer on next. Signing is local-keys for now (bots hold
9//! their nsec); a NIP-46 bunker create/send path is a documented follow-up (v2's
10//! genesis + chat seals are sign-only ops, so it composes — just needs the async
11//! signer threaded through `build_seal`).
12
13use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Timestamp};
14
15use super::super::transport::{Query, Transport};
16use super::super::{version, ChannelId, Epoch};
17use super::chat::{self, ChatEvent};
18use super::community::{ChannelV2, CommunityV2};
19use super::control;
20use super::derive::{base_rekey_group_key, channel_group_key, channel_rekey_group_key, control_group_key, GroupKey};
21use super::invite::{self, CommunityInvite};
22use super::rekey::{self, Continuity, RekeyScope};
23use super::{guestbook, stream, vsk};
24use crate::community::edition::ParsedEdition;
25use crate::state::SessionGuard;
26
27/// The local identity keys, or an error if none is installed. First-cut signing
28/// path (a bunker account routes through the async signer — a follow-up).
29fn local_keys() -> Result<Keys, String> {
30    crate::state::MY_SECRET_KEY
31        .to_keys()
32        .ok_or_else(|| "Concord v2 needs a local identity key (bunker create/send is not wired yet)".to_string())
33}
34
35fn now_ms() -> u64 {
36    std::time::SystemTime::now()
37        .duration_since(std::time::UNIX_EPOCH)
38        .map(|d| d.as_millis() as u64)
39        .unwrap_or(0)
40}
41
42/// Create a fresh v2 community owned by the local identity: mint the genesis
43/// (self-certifying id + the two owner editions), persist, publish the genesis
44/// control editions, and announce the owner's Guestbook Join. Returns the saved
45/// community.
46pub async fn create_community<T: Transport + ?Sized>(
47    transport: &T,
48    name: &str,
49    relays: Vec<String>,
50    description: Option<String>,
51) -> Result<CommunityV2, String> {
52    let session = SessionGuard::capture();
53    let owner = local_keys()?;
54    let at_ms = now_ms();
55
56    let meta = control::CommunityMetadata {
57        name: name.to_string(),
58        description: description.clone(),
59        relays: relays.clone(),
60        ..Default::default()
61    };
62    let genesis = control::genesis(&owner, meta, at_ms / 1000).map_err(|e| e.to_string())?;
63    let community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
64
65    // Save-before-publish (like v1 create): no peers exist yet so there's no
66    // shared view to diverge from, and the fresh-random keys are irrecoverable
67    // if a publish hiccup rolled them back. Re-check the session first — genesis
68    // signing straddled no await here, but the DB write is the side effect.
69    if !session.is_valid() {
70        return Err("account changed during community creation".to_string());
71    }
72    // Seed the genesis edition heads (v1) as the owner's refuse-downgrade floor, so a
73    // later edit can't be rolled back by a relay serving only the genesis prefix. The
74    // live control sub is replay-free (limit 0), so the owner won't re-fold its own
75    // genesis to seed the floor otherwise. Floors land BEFORE the community row
76    // (floors-then-state ordering).
77    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
78    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
79    for wrap in &genesis.wraps {
80        if let Ok((ed, _)) = control::open_control_edition(wrap, &control) {
81            let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
82            crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
83        }
84    }
85    crate::db::community::save_community_v2(&community)?;
86    // Archive the genesis root at epoch 0, so a later Refounding leaves this epoch's
87    // Public-channel history readable (CORD-03 §3 multi-epoch read).
88    let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
89
90    // Publish the two genesis control editions at the epoch-0 control plane.
91    // Durable, not single-shot: over a slow transport (Tor) one attempt is a coin
92    // flip, and a lost genesis leaves a community that exists only locally. Durable
93    // races every relay, returns on the first ACK, then heals stragglers in the bg.
94    for wrap in &genesis.wraps {
95        transport.publish_durable(wrap, &community.relays).await?;
96    }
97
98    // Announce the owner's Guestbook Join so they appear in the memberlist. Relays are
99    // proven-alive by the genesis ACK above, so durable here just guarantees the owner's
100    // own join lands (member count) without a real block risk.
101    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
102    let join_rumor = guestbook::build_join_rumor(owner.public_key(), None, at_ms);
103    if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor(&join_rumor, &gb_group, &owner, Timestamp::from_secs(at_ms / 1000)) {
104        let _ = transport.publish_durable(&join_wrap, &community.relays).await;
105    }
106
107    // Sync the new membership across devices (CORD-02 §8) — best-effort.
108    let _ = republish_community_list(transport, Some(community.id())).await;
109    Ok(community)
110}
111
112/// Send a text message to a channel. Derives the channel's Chat-Plane group key
113/// (community_root for a Public channel, the channel key for a Private one),
114/// seals it encrypted, and publishes. Returns the message's rumor id (hex).
115pub async fn send_message<T: Transport + ?Sized>(
116    transport: &T,
117    community: &CommunityV2,
118    channel_id: &ChannelId,
119    content: &str,
120) -> Result<String, String> {
121    send_chat_message(transport, community, channel_id, content, None, &[], vec![]).await
122}
123
124/// Full chat send: threaded reply (NIP-C7 `q`, the parent's `(rumor_id, author)`
125/// hex pair), NIP-30 custom-emoji pairs, and verbatim extra tags (NIP-92 `imeta`
126/// attachments). Returns the message's rumor id (hex).
127pub async fn send_chat_message<T: Transport + ?Sized>(
128    transport: &T,
129    community: &CommunityV2,
130    channel_id: &ChannelId,
131    content: &str,
132    reply_to: Option<(&str, &str)>,
133    emoji: &[(&str, &str)],
134    extra_tags: Vec<nostr_sdk::prelude::Tag>,
135) -> Result<String, String> {
136    send_chat_message_at(transport, community, channel_id, content, reply_to, emoji, extra_tags, now_ms()).await
137}
138
139/// [`send_chat_message`] with an explicit event time. The rumor id is a pure
140/// function of its inputs, so a GUI that picks `at_ms` can precompute the id for
141/// its optimistic pending row — the in-process echo and the finalize then key
142/// the SAME id (the exact v1 pending → sent contract).
143#[allow(clippy::too_many_arguments)]
144pub async fn send_chat_message_at<T: Transport + ?Sized>(
145    transport: &T,
146    community: &CommunityV2,
147    channel_id: &ChannelId,
148    content: &str,
149    reply_to: Option<(&str, &str)>,
150    emoji: &[(&str, &str)],
151    extra_tags: Vec<nostr_sdk::prelude::Tag>,
152    at_ms: u64,
153) -> Result<String, String> {
154    let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
155    let rumor = chat::build_message_rumor(author.public_key(), channel_id, epoch, content, reply_to, emoji, extra_tags, at_ms);
156    publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
157}
158
159/// React to a channel message (kind 7, NIP-25 shape). `target_id_hex` /
160/// `target_author_hex` name the reacted-to message; `target_kind` is its rumor
161/// kind (`kind::MESSAGE`, or `kind::COMMENT` for a threaded reply); `emoji`
162/// carries the NIP-30 pair when `emoji_content` is a custom `:shortcode:`.
163#[allow(clippy::too_many_arguments)]
164pub async fn send_reaction<T: Transport + ?Sized>(
165    transport: &T,
166    community: &CommunityV2,
167    channel_id: &ChannelId,
168    target_id_hex: &str,
169    target_author_hex: &str,
170    target_kind: u16,
171    emoji_content: &str,
172    emoji: Option<(&str, &str)>,
173) -> Result<String, String> {
174    let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
175    let at_ms = now_ms();
176    let rumor =
177        chat::build_reaction_rumor(author.public_key(), channel_id, epoch, target_id_hex, target_author_hex, target_kind, emoji_content, emoji, at_ms);
178    publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
179}
180
181/// Edit one of your own messages (kind 3302): peers re-render `target_id_hex`
182/// with the replacement text. Author-enforced on the read side — only the
183/// original author's edit folds.
184pub async fn send_edit<T: Transport + ?Sized>(
185    transport: &T,
186    community: &CommunityV2,
187    channel_id: &ChannelId,
188    target_id_hex: &str,
189    new_content: &str,
190) -> Result<String, String> {
191    let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
192    let at_ms = now_ms();
193    let rumor = chat::build_edit_rumor(author.public_key(), channel_id, epoch, target_id_hex, new_content, at_ms);
194    publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
195}
196
197/// Cooperative in-plane delete (kind 5, NIP-09 semantics): peers stop rendering
198/// `target_id_hex`. The wrap ciphertext on relays is scrubbed separately via the
199/// retained per-message stream key (see `publish_chat`).
200pub async fn send_delete<T: Transport + ?Sized>(
201    transport: &T,
202    community: &CommunityV2,
203    channel_id: &ChannelId,
204    target_id_hex: &str,
205    target_kind: u16,
206) -> Result<String, String> {
207    let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
208    let at_ms = now_ms();
209    let rumor = chat::build_delete_rumor(author.public_key(), channel_id, epoch, target_id_hex, target_kind, at_ms);
210    publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
211}
212
213/// WebXDC realtime peer signal (kind 3310) — the v2 twin of v1's
214/// `publish_webxdc_signal`: the same shared content shape, sealed on the
215/// channel's chat plane, DURABLE (a reopening peer backfills a recent ad).
216/// Signed by the member's real identity — a member can't forge another
217/// player's presence. Failure is non-fatal to callers (the next re-advertise
218/// covers a missed ad).
219pub async fn send_webxdc_signal<T: Transport + ?Sized>(
220    transport: &T,
221    community: &CommunityV2,
222    channel_id: &ChannelId,
223    topic_id: &str,
224    node_addr: Option<&str>,
225) -> Result<(), String> {
226    let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
227    let at_ms = now_ms();
228    let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
229    let rumor = chat::build_webxdc_rumor(author.public_key(), channel_id, epoch, &content, vec![], at_ms);
230    publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await.map(|_| ())
231}
232
233/// Ephemeral typing indicator (kind 23311 in a 21059 wrap — relays never store it).
234pub async fn send_typing<T: Transport + ?Sized>(
235    transport: &T,
236    community: &CommunityV2,
237    channel_id: &ChannelId,
238) -> Result<(), String> {
239    let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
240    let at_ms = now_ms();
241    let rumor = chat::build_typing_rumor(author.public_key(), channel_id, epoch, at_ms);
242    publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
243}
244
245/// Everything a chat-plane send needs: local keys, the channel's group key +
246/// epoch, and the session snapshot taken BEFORE any await. Refuses a dissolved
247/// community (every honest member sealed it read-only) and a keyless Private
248/// channel — deriving from the root would post to the public plane; its key
249/// arrives over the rekey plane.
250fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(Keys, GroupKey, Epoch, SessionGuard), String> {
251    let session = SessionGuard::capture();
252    let author = local_keys()?;
253    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
254    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
255        return Err("this community has been dissolved".to_string());
256    }
257    // A self-ban: every honest peer drops our events (CORD-04 §4) and the send
258    // echo would silently no-op, so fail loudly instead of a message that seems
259    // to send but shows up nowhere.
260    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author.public_key().to_hex()) {
261        return Err("you are banned from this community".to_string());
262    }
263    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
264    if ch.private && ch.key.is_none() {
265        return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
266    }
267    let (secret, epoch) = community.channel_secret(ch);
268    Ok((author, channel_group_key(&secret, channel_id, epoch), epoch, session))
269}
270
271/// Seal one chat rumor, re-check the session, publish, and echo the send into the
272/// shared store. Returns the rumor id (hex).
273#[allow(clippy::too_many_arguments)]
274async fn publish_chat<T: Transport + ?Sized>(
275    transport: &T,
276    community: &CommunityV2,
277    session: &SessionGuard,
278    group: &GroupKey,
279    author: &Keys,
280    channel_id: &ChannelId,
281    epoch: Epoch,
282    rumor: nostr_sdk::prelude::UnsignedEvent,
283    at_ms: u64,
284    ephemeral: bool,
285) -> Result<String, String> {
286    let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
287    let (wrap, _p_tag_keys) = chat::seal_chat_rumor(&rumor, group, author, Timestamp::from_secs(at_ms / 1000), ephemeral)
288        .map_err(|e| e.to_string())?;
289    if !session.is_valid() {
290        return Err("account changed before send".to_string());
291    }
292    transport.publish(&wrap, &community.relays).await?;
293    // Retain the wrap's signing key (the group stream key) keyed by rumor id so a
294    // full delete can NIP-09 this exact wrap off relays (same-author rule, honored
295    // everywhere — the discarded p-tag pair only works on recipient-delete relays).
296    // Frozen per-message so later rekeys can't strand it. Session-gated: the publish
297    // straddled network I/O.
298    if !ephemeral {
299        if !session.is_valid() {
300            return Ok(rumor_id);
301        }
302        crate::db::community::store_message_key(&rumor_id, &wrap.id.to_hex(), group.keys(), &community.relays)?;
303    }
304    // Local echo (v1 parity): open our OWN wrap through the exact inbound path so
305    // send-then-read works with no listen loop, and the relay's re-delivery dedups
306    // against this row instead of re-firing callbacks. Best-effort — the publish
307    // already succeeded. Ephemeral kinds (typing) apply to nothing and skip out.
308    if !ephemeral {
309        if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
310            let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
311            let outcome = {
312                let mut st = crate::state::STATE.lock().await;
313                if !session.is_valid() {
314                    return Ok(rumor_id); // swapped on the lock await — never echo into another account.
315                }
316                super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author.public_key())
317            };
318            if let Some(outcome) = outcome {
319                if !session.is_valid() {
320                    return Ok(rumor_id);
321                }
322                super::inbound::persist_chat(&channel_hex, &outcome).await;
323            }
324        }
325    }
326    Ok(rumor_id)
327}
328
329/// A chat event opened from a channel fetch, tagged with the epoch its key
330/// decrypted under.
331pub struct FetchedEvent {
332    pub event: ChatEvent,
333    pub epoch: Epoch,
334}
335
336/// Self-heal scrub-key retention for an OWN rumor seen during a history open:
337/// pre-retention and other-device sends stay fully deletable, because the wrap's
338/// signing key is the derivable group stream key — only this rumor→wrap mapping
339/// was ever missing locally. No-op for foreign authors, kinds the UI can't
340/// delete, and already-retained rows. Best-effort: a store failure never breaks
341/// the fetch.
342fn heal_own_wrap_key(event: &ChatEvent, group: &GroupKey, relays: &[String]) {
343    if !matches!(event, ChatEvent::Message { .. } | ChatEvent::Reaction { .. }) {
344        return;
345    }
346    let opened = event.opened();
347    if crate::state::my_public_key() != Some(opened.author) {
348        return;
349    }
350    let rumor_hex = opened.rumor_id.to_hex();
351    // Only fill a confirmed gap — never clobber a send-time row, never write
352    // when the store can't be read.
353    if !matches!(crate::db::community::get_message_key(&rumor_hex), Ok(None)) {
354        return;
355    }
356    if crate::db::community::store_message_key(&rumor_hex, &opened.wrapper_id.to_hex(), group.keys(), relays).is_ok() {
357        // The UI caches full-vs-limited delete verdicts per message; tell it this
358        // one just flipped so it re-resolves without an app restart.
359        crate::traits::emit_event("message_delete_meta_changed", &serde_json::json!({ "id": rumor_hex }));
360    }
361}
362
363/// Fetch a channel's newest messages — one page of [`fetch_channel_history`].
364/// `limit` is one relay-side bound across the whole epoch-author OR-set, not
365/// per epoch; deeper history pages backwards via the walk.
366pub async fn fetch_channel<T: Transport + ?Sized>(
367    transport: &T,
368    community: &CommunityV2,
369    channel_id: &ChannelId,
370    limit: usize,
371) -> Result<Vec<FetchedEvent>, String> {
372    fetch_channel_history(transport, community, channel_id, limit, 1, |_| true).await
373}
374
375/// Walk a channel's history newest-first (CORD-03 §3 "clients load a Channel
376/// newest-first and paginate backwards"), querying every held epoch's Chat-Plane
377/// address one `page`-sized query at a time until `max_pages`, a drained relay,
378/// or `keep_paging` returns false for a page (the caller's "I already hold
379/// these" early stop — consulted only on pages that opened something, so junk
380/// at the address can't fake exhaustion). Pages step by INCLUSIVE `until` with
381/// wrap-id dedup, so a page boundary landing mid-second can't skip siblings; a
382/// full page of only-already-seen wraps is a same-second WALL (relay filters
383/// are second-granular) and steps past it accepting that unseen same-second
384/// siblings beyond the relay cap are unreachable — logged, and a protocol-level
385/// limitation (the `ms` tag can't be filtered server-side).
386///
387/// Returns everything opened, deduped by rumor id, oldest→newest.
388pub async fn fetch_channel_history<T: Transport + ?Sized>(
389    transport: &T,
390    community: &CommunityV2,
391    channel_id: &ChannelId,
392    page: usize,
393    max_pages: usize,
394    mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
395) -> Result<Vec<FetchedEvent>, String> {
396    // Guards the opportunistic scrub-key heals below — the fetch loop straddles
397    // network I/O, and an account swap must not write into the new account's DB.
398    let session = SessionGuard::capture();
399    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
400    // A Public channel reads across EVERY held base-root epoch, and a Private one
401    // across its OWN held epochs (CORD-03 §3), so history spanning a rotation stays
402    // continuous either way. A keyless Private channel is unreadable — never derived
403    // from the root (that would address the public plane).
404    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
405    let coords: Vec<([u8; 32], Epoch)> = if ch.private {
406        let Some(current) = ch.key else {
407            return Ok(Vec::new());
408        };
409        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
410        let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
411        if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
412            held.push((ch.epoch, current));
413        }
414        // Only real grants are archived, but keep the invariant local: a private
415        // plane is never read with the root value.
416        held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
417    } else {
418        let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
419        if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
420            roots.push((community.root_epoch, community.community_root));
421        }
422        roots.into_iter().map(|(ep, root)| (root, ep)).collect()
423    };
424    if coords.is_empty() {
425        return Ok(Vec::new());
426    }
427
428    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
429    let mut seen_rumors = std::collections::HashSet::new();
430    let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
431    let mut until: Option<u64> = None;
432    let mut oldest: Option<u64> = None;
433    for _ in 0..max_pages {
434        // Fetch each held epoch's Chat-Plane AUTHED AS that plane key. AUTH-gating
435        // relays (Ditto) require the connection authed as the author queried and
436        // reject a multi-author REQ ("all authors must be authenticated"), so a
437        // single merged fetch returns nothing there — the latest messages under a
438        // freshly-adopted epoch never load. Per-plane authed fetches + union.
439        let mut wraps: Vec<Event> = Vec::new();
440        let mut wrap_ids: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
441        for (secret, epoch) in &coords {
442            let plane = channel_group_key(secret, channel_id, *epoch);
443            let q = Query {
444                kinds: vec![stream::KIND_WRAP],
445                authors: vec![plane.pk_hex()],
446                until,
447                limit: Some(page),
448                ..Default::default()
449            };
450            if let Ok(evs) = transport.fetch_plane(plane.keys(), &q, &community.relays).await {
451                for e in evs {
452                    if wrap_ids.insert(e.id) {
453                        wraps.push(e);
454                    }
455                }
456            }
457        }
458        if wraps.is_empty() {
459            break;
460        }
461        let mut fresh = 0usize;
462        let mut page_events: Vec<FetchedEvent> = Vec::new();
463        for wrap in &wraps {
464            if !seen_wraps.insert(wrap.id) {
465                continue;
466            }
467            fresh += 1;
468            let at = wrap.created_at.as_secs();
469            if oldest.is_none_or(|o| at < o) {
470                oldest = Some(at);
471            }
472            // Select the epoch whose group key authored this wrap (no trial decrypt).
473            for (secret, epoch) in &coords {
474                let group = channel_group_key(secret, channel_id, *epoch);
475                if wrap.pubkey != group.pk() {
476                    continue;
477                }
478                if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
479                    let id = event.opened().rumor_id;
480                    if seen_rumors.insert(id) {
481                        if session.is_valid() {
482                            heal_own_wrap_key(&event, &group, &community.relays);
483                        }
484                        page_events.push(FetchedEvent { event, epoch: *epoch });
485                    }
486                }
487                break;
488            }
489        }
490        if fresh == 0 {
491            if wraps.len() < page {
492                break; // drained — the relay has nothing older.
493            }
494            // A full page of already-seen wraps: a same-second WALL. Step past it;
495            // same-second siblings beyond the relay's cap are unreachable by a
496            // second-granular filter.
497            let Some(o) = oldest else { break };
498            if o == 0 {
499                break;
500            }
501            crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
502            until = Some(o - 1);
503            continue;
504        }
505        let stop = !page_events.is_empty() && !keep_paging(&page_events);
506        out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
507        if stop {
508            break; // the caller holds everything from here back.
509        }
510        until = oldest; // inclusive — wrap-id dedup absorbs the boundary overlap.
511    }
512    out.sort_by_key(|(ms, _)| *ms);
513    Ok(out.into_iter().map(|(_, e)| e).collect())
514}
515
516// ── Invites (CORD-05) ────────────────────────────────────────────────────────
517
518/// Build the §1 invite bundle for this community. Every channel is granted: a
519/// Public channel carries the `community_root` as its "key" (the joiner derives
520/// the real secret from the root), a Private one its own key. The bundle
521/// self-certifies the owner, so the inviter's identity is irrelevant to trust.
522pub fn bundle_of(
523    community: &CommunityV2,
524    creator: Option<PublicKey>,
525    expires_at_ms: Option<u64>,
526    label: Option<String>,
527) -> CommunityInvite {
528    let hex = crate::simd::hex::bytes_to_hex_32;
529    let channels = community
530        .channels
531        .iter()
532        // A KEYLESS private channel can't be granted (we hold no key) — carrying the
533        // root placeholder would make the joiner classify it PUBLIC and address a
534        // private channel at the public plane. It joins their view via control-follow
535        // (keyless) and keys up at the channel's next rotation.
536        .filter(|c| !(c.private && c.key.is_none()))
537        .map(|c| invite::ChannelGrant {
538            id: hex(&c.id.0),
539            key: hex(&c.key.unwrap_or(community.community_root)),
540            epoch: c.epoch.0,
541            name: c.name.clone(),
542        })
543        .collect();
544    CommunityInvite {
545        community_id: hex(&community.identity.community_id.0),
546        owner: hex(&community.identity.owner_xonly),
547        owner_salt: hex(&community.identity.owner_salt),
548        community_root: hex(&community.community_root),
549        root_epoch: community.root_epoch.0,
550        channels,
551        relays: community.relays.clone(),
552        name: community.name.clone(),
553        // Mint-time snapshot so a parked invite renders the real logo before any
554        // fold; the Control Plane stays the authority after joining.
555        icon: community.icon.clone(),
556        expires_at: expires_at_ms,
557        creator_npub: creator.map(|p| p.to_hex()),
558        label,
559        extra: Default::default(),
560    }
561}
562
563/// Gift-wrap a Direct Invite (kind 3313) of this community straight to `recipient`
564/// and publish it to the community relays. `expires_at_ms` (unix ms) optionally
565/// bounds its shelf life; `label` is echoed in the joiner's Guestbook Join. The
566/// bundle hands over the keys; the recipient consents by accepting (nothing joins
567/// on receipt). Returns the wrap.
568pub async fn send_direct_invite<T: Transport + ?Sized>(
569    transport: &T,
570    community: &CommunityV2,
571    recipient: &PublicKey,
572    expires_at_ms: Option<u64>,
573    label: Option<String>,
574) -> Result<Event, String> {
575    let session = SessionGuard::capture();
576    let inviter = local_keys()?;
577    let bundle = bundle_of(community, Some(inviter.public_key()), expires_at_ms, label);
578    let wrap = invite::build_direct_invite(&inviter, recipient, &bundle).map_err(|e| e.to_string())?;
579    if !session.is_valid() {
580        return Err("account changed before sending invite".to_string());
581    }
582    transport.publish(&wrap, &community.relays).await?;
583    Ok(wrap)
584}
585
586/// A minted public link: the shareable URL plus the addressable bundle event to
587/// publish and the link keypair to retain (in the Invite List) for later refresh
588/// or revocation.
589pub struct MintedLink {
590    pub url: String,
591    pub bundle_event: Event,
592    pub link_signer: Keys,
593    pub token: [u8; super::derive::TOKEN_LEN],
594}
595
596/// Mint a public invite link for this community: a fresh token + link keypair, the
597/// bundle encrypted under the token key and published at `(33301, link_signer,
598/// "")`, and the `base/invite/<naddr>#<fragment>` URL. `base` is the deep-link
599/// domain (e.g. `https://vectorapp.io`); the fragment carries the token + bootstrap
600/// relays and never reaches a server.
601pub async fn mint_public_link<T: Transport + ?Sized>(
602    transport: &T,
603    community: &CommunityV2,
604    base: &str,
605    expires_at_ms: Option<u64>,
606    label: Option<String>,
607) -> Result<MintedLink, String> {
608    let session = SessionGuard::capture();
609    let mut token = [0u8; super::derive::TOKEN_LEN];
610    token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
611    let link_signer = Keys::generate();
612    let bundle = bundle_of(community, Some(local_keys()?.public_key()), expires_at_ms, label.clone());
613    let bundle_key = super::derive::invite_bundle_key(&token);
614    let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
615    let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
616
617    if !session.is_valid() {
618        return Err("account changed before minting link".to_string());
619    }
620    transport.publish_durable(&bundle_event, &community.relays).await?;
621    let minted = MintedLink { url, bundle_event, link_signer, token };
622    // Sync the link across the creator's devices (13303) + publish the Registry
623    // (vsk-8) so members see the community is Public. Best-effort — the link works
624    // without the sync.
625    let _ = record_minted_link(transport, community, &minted).await;
626    // Local mirror so `list_public_invites` stays a sync local read (v1 parity);
627    // the 13303 list remains the cross-device record. Re-check the session: the
628    // publishes above straddled awaits, and this write must not land account A's
629    // link (secret token included) in a swapped-in account's DB.
630    if session.is_valid() {
631        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
632        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
633        let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
634    }
635    Ok(minted)
636}
637
638// ── The Invite Registry (vsk 8) + Invite List (13303), CORD-05 §4/§5 ──────────
639
640/// Fetch the creator's own 13303 Invite List from `relays` (newest wins; a
641/// decrypt/parse failure is "no news", never a clobber of the local mirror).
642/// Transport failure is Err, NOT None: the 13303 is REPLACEABLE, so a caller
643/// that mistakes "couldn't reach the relays" for "no list yet" and publishes a
644/// fresh one wipes every link minted on other devices. Full evidence for the
645/// same reason — this read feeds replaceable-event writes.
646async fn fetch_invite_list<T: Transport + ?Sized>(
647    transport: &T,
648    relays: &[String],
649) -> Result<Option<invite::InviteList>, String> {
650    let me = local_keys()?;
651    let query = Query {
652        kinds: vec![super::kind::INVITE_LIST],
653        authors: vec![me.public_key().to_hex()],
654        limit: Some(4),
655        evidence: crate::community::transport::Evidence::Full,
656        ..Default::default()
657    };
658    let events = transport.fetch(&query, relays).await?;
659    Ok(events
660        .into_iter()
661        .filter_map(|e| invite::parse_invite_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
662        .max_by_key(|(at, _)| *at)
663        .map(|(_, l)| l))
664}
665
666/// The creator's LIVE (non-tombstoned) link-signer pubkeys for one community — the
667/// Registry's content (CORD-05 §5), derived from the stored link secrets.
668fn live_signers_for(list: &invite::InviteList, community_id_hex: &str) -> Vec<PublicKey> {
669    let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
670    list.entries
671        .iter()
672        .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
673        .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
674        .collect()
675}
676
677/// Publish the creator's Registry (vsk-8) edition — their live link signers for this
678/// community — so members fold it into the Public/Private source of truth (a
679/// non-empty aggregate = Public).
680async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
681    let me = local_keys()?;
682    let eid = super::derive::invite_links_locator(community.id(), &me.public_key().to_bytes());
683    let content = invite::build_registry_content(live_signers);
684    publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content, None).await
685}
686
687/// Record a freshly-minted public link across the creator's devices: append it to the
688/// 13303 Invite List and refresh the Registry (CORD-05 §4/§5).
689async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
690    let session = SessionGuard::capture();
691    let me = local_keys()?;
692    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
693    let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
694    // Err aborts the sync half (the link's bundle already published durably;
695    // a retry re-records it) — an unreachable relay set must never be mistaken
696    // for "no list yet" and clobber the replaceable 13303. Ok(None) IS a fresh
697    // creator's honest first list.
698    let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
699    if !list.entries.iter().any(|e| e.token == token_hex) {
700        list.entries.push(invite::InviteEntry {
701            token: token_hex,
702            signer_sk: minted.link_signer.secret_key().to_secret_hex(),
703            community_id: cid_hex.clone(),
704            url: minted.url.clone(),
705            label: None,
706            created_at: now_ms() / 1000,
707            expires_at: None,
708            extra: Default::default(),
709        });
710    }
711    if !session.is_valid() {
712        return Err("account changed during link record".to_string());
713    }
714    let event = invite::build_invite_list_event(&me, &list).map_err(|e| e.to_string())?;
715    transport.publish(&event, &community.relays).await?;
716    let signers = live_signers_for(&list, &cid_hex);
717    publish_invite_registry(transport, community, &session, &signers).await
718}
719
720/// Revoke a public link by its token hex (CORD-05 §2/§5): re-post its coordinate as a
721/// revocation tombstone (retiring the bundle behind the URL, so a fetcher finds the
722/// grave), tombstone the Invite List entry, and refresh the Registry. Retiring the
723/// LAST live link empties the Registry → the community reads Private (a Refounding is
724/// the owner's separate read-cut).
725pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
726    let session = SessionGuard::capture();
727    let me = local_keys()?;
728    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
729    let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
730    let entry = list
731        .entries
732        .iter()
733        .find(|e| e.token == token_hex && e.community_id == cid_hex)
734        .cloned()
735        .ok_or("no such link in the invite list")?;
736    // Re-post the bundle coordinate as a revocation tombstone (creator-signed).
737    let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
738    let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
739    if !session.is_valid() {
740        return Err("account changed during revoke".to_string());
741    }
742    transport.publish_durable(&revocation, &community.relays).await?;
743    // Tombstone the Invite List entry (permanent — a stale device can't resurrect it).
744    list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
745    list.entries.retain(|e| e.token != token_hex);
746    let event = invite::build_invite_list_event(&me, &list).map_err(|e| e.to_string())?;
747    transport.publish(&event, &community.relays).await?;
748    let signers = live_signers_for(&list, &cid_hex);
749    publish_invite_registry(transport, community, &session, &signers).await?;
750    // Drop the local mirror row (sibling of the mint-time save) — only if still our session.
751    if session.is_valid() {
752        let _ = crate::db::community::delete_public_invite(token_hex);
753    }
754    Ok(())
755}
756
757/// Refresh every live public link's bundle behind its stable URL (CORD-05 §2) — e.g.
758/// after a Rekey/Refounding rolled the keys — by re-posting the bundle at the same
759/// coordinate with the CURRENT community state, so a link shared once keeps working
760/// across rotations. Best-effort.
761pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
762    let session = SessionGuard::capture();
763    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
764    // Fetch inline (not via fetch_invite_list) so a TRANSPORT FAILURE propagates as
765    // Err — the caller (a post-refounding refresh) must be able to retry, or live
766    // links keep serving the PRE-refound root and new joiners land on the dead
767    // epoch. A genuinely-empty list is Ok (nothing to refresh).
768    let me = local_keys()?;
769    let query = Query {
770        kinds: vec![super::kind::INVITE_LIST],
771        authors: vec![me.public_key().to_hex()],
772        limit: Some(4),
773        ..Default::default()
774    };
775    let events = transport.fetch(&query, &community.relays).await?;
776    let list = events
777        .into_iter()
778        .filter_map(|e| invite::parse_invite_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
779        .max_by_key(|(at, _)| *at)
780        .map(|(_, l)| l);
781    let Some(list) = list else {
782        return Ok(());
783    };
784    let creator = local_keys()?.public_key();
785    let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
786    for entry in &list.entries {
787        if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
788            continue;
789        }
790        let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
791        let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
792        let bundle = bundle_of(community, Some(creator), entry.expires_at, entry.label.clone());
793        let bundle_key = super::derive::invite_bundle_key(&token);
794        if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
795            if !session.is_valid() {
796                return Err("account changed during link refresh".to_string());
797            }
798            let _ = transport.publish_durable(&event, &community.relays).await;
799        }
800    }
801    Ok(())
802}
803
804/// Whether this community is PUBLIC (CORD-05 §5): fold every creator's Registry
805/// (vsk-8) that its author is authorized for (`CREATE_INVITE`, bound to their
806/// coordinate) into an aggregate live-link set — non-empty ⇒ a live link exists ⇒
807/// Public; empty ⇒ Private. Retiring the last link is what flips it back.
808pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
809    use crate::community::roles::Permissions;
810    use std::collections::BTreeMap;
811    let Ok(owner) = community.owner() else { return false };
812    let owner_hex = owner.to_hex();
813    let cid = community.id();
814    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
815    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
816        .unwrap_or_default()
817        .into_iter()
818        .filter(|(_, f)| f.0 == community.root_epoch.0)
819        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
820        .collect();
821    let control = control_group_key(&community.community_root, cid, community.root_epoch);
822    let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![control.pk_hex()], limit: Some(FOLLOW_PAGE), ..Default::default() };
823    let editions: Vec<ParsedEdition> = transport
824        .fetch(&query, &community.relays)
825        .await
826        .unwrap_or_default()
827        .iter()
828        .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(ed, _)| ed))
829        .collect();
830    let authority = fold_authority(community, &editions, &floors);
831
832    let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
833    for e in &editions {
834        if e.vsk == vsk::INVITE_LINKS {
835            by_eid.entry(e.entity_id).or_default().push(e);
836        }
837    }
838    for (eid, group) in &by_eid {
839        let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
840        let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
841        let head = group[hi];
842        // The creator must hold CREATE_INVITE AND own this coordinate.
843        if !authority.roles.is_authorized(&head.author.to_hex(), Some(&owner_hex), Permissions::CREATE_INVITE) {
844            continue;
845        }
846        if super::derive::invite_links_locator(cid, &head.author.to_bytes()) != *eid {
847            continue;
848        }
849        if invite::parse_registry_content(&head.content).map(|s| !s.is_empty()).unwrap_or(false) {
850            return true;
851        }
852    }
853    false
854}
855
856/// Accept an already-unwrapped bundle: verify the owner commitment AND that the
857/// delivered community_root is genuinely the owner's, persist the community, and
858/// announce a Guestbook Join (with invite attribution). Shared tail of both accept
859/// paths. Takes the caller's `SessionGuard` (captured BEFORE any network fetch the
860/// caller did) so the `is_valid()` gate straddles that I/O.
861async fn accept_bundle<T: Transport + ?Sized>(
862    transport: &T,
863    session: &SessionGuard,
864    bundle: &CommunityInvite,
865    invited_by: Option<PublicKey>,
866) -> Result<CommunityV2, String> {
867    let me = local_keys()?;
868    let at_ms = now_ms();
869    // Expiry gate: a past invite still previews but must not join (CORD-05 §1).
870    if bundle.expired(at_ms) {
871        return Err("this invite has expired".to_string());
872    }
873    // `from_bundle` re-validates bounds + the owner commitment fail-closed.
874    let community = CommunityV2::from_bundle(bundle, at_ms)?;
875
876    // Authenticate the delivered community_root before trusting it. The owner
877    // commitment proves WHO the owner is, but community_root (and channel keys) are
878    // NOT in that commitment, so a forged invite can pair a real (id, owner, salt)
879    // with an attacker-chosen root and silently partition the joiner onto planes
880    // only the attacker controls. Requiring the owner's genesis to open under the
881    // delivered root closes that eclipse; also reconciles channel classification.
882    // A preview verified the SAME (id, root) moments ago → reuse its fold instead
883    // of re-walking the plane (the bundle re-fetch above kept the revocation gate).
884    let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
885        v.session.is_valid()
886            && v.at.elapsed() < VERIFIED_PREVIEW_TTL
887            && v.community_id == community.id().0
888            && v.community_root == community.community_root
889    });
890    let (community, join_heads) = match handoff {
891        Some(v) => {
892            let mut c = v.folded;
893            // The preview holds no acquisition time — stamp the JOIN's.
894            c.created_at_ms = at_ms;
895            (c, v.heads)
896        }
897        None => verify_owner_root_and_reconcile(transport, community).await?,
898    };
899
900    // A dissolved community is a grave (CORD-02 §9): refuse to join it.
901    if is_dissolved(transport, &community).await {
902        return Err("this community has been dissolved".to_string());
903    }
904
905    // The account must not have swapped since the guard was captured (which was
906    // before any fetch the caller / the verify above performed) — else we'd write
907    // A's join into B.
908    if !session.is_valid() {
909        return Err("account changed during join".to_string());
910    }
911    // Seed the verified heads as the initial refuse-downgrade floor BEFORE the
912    // community row lands (floors-then-state, so a mid-seed error can't leave saved
913    // state outrunning its floor); the first post-join follow then can't persist a
914    // state below what this join already showed.
915    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
916    for h in &join_heads {
917        crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, community.root_epoch.0)?;
918    }
919    crate::db::community::save_community_v2(&community)?;
920    // Archive the joined root at its epoch, so this member reads Public-channel
921    // history from their join epoch onward across later Refoundings (CORD-03 §3).
922    let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
923    // Same for each granted Private-channel key: the archive is what lets its
924    // history stay readable after the channel rotates away from this key.
925    for ch in &community.channels {
926        if let (true, Some(key)) = (ch.private, ch.key) {
927            let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
928        }
929    }
930
931    // Announce our Guestbook Join, echoing the invite attribution when present.
932    let attribution = invited_by
933        .map(|p| p.to_hex())
934        .or_else(|| bundle.creator_npub.clone())
935        .zip(Some(bundle.label.clone().unwrap_or_default()));
936    let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
937    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
938    let join_rumor = guestbook::build_join_rumor(me.public_key(), attr_ref, at_ms);
939    if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor(&join_rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000)) {
940        let _ = transport.publish(&join_wrap, &community.relays).await;
941    }
942
943    // Record the membership across devices (CORD-02 §8) — best-effort.
944    let _ = republish_community_list(transport, Some(community.id())).await;
945    Ok(community)
946}
947
948/// Prove the delivered `community_root` is genuinely the owner's, and reconcile
949/// channel classification from the owner's editions. `community_id` commits only
950/// to `(owner_xonly, owner_salt)` — both semi-public (they ride every bundle and
951/// every synced Community List) — so a forged invite can present a real community's
952/// id/owner/salt with an attacker-chosen root; every plane then derives from that
953/// root, silently eclipsing the joiner onto attacker-controlled addresses while the
954/// owner commitment still "verifies". The defense: the owner's genesis metadata
955/// edition (vsk-0, `eid == community_id`) only opens under the AUTHENTIC root — an
956/// attacker can't forge the owner's seal — so its presence on the control plane
957/// derived from the delivered root proves that root. Fail-closed: no owner genesis
958/// (forged invite, or relays unreachable) → refuse to join. On success, folds the
959/// owner's authoritative editions to heal a bundle that misclassified a channel.
960async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
961    transport: &T,
962    community: CommunityV2,
963) -> Result<(CommunityV2, Vec<FoldedHead>), String> {
964    let owner = community.owner()?;
965    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
966    let control_pk = control.pk_hex();
967
968    // AUTH-gating relays (ditto-relay's default gates kind-1059) serve a plane's
969    // wraps ONLY to a connection authenticated AS the stream key — Concord's
970    // group-addressed wraps aren't p-tagged to the joiner, so the login alone can't
971    // satisfy the gate and the control plane reads back empty. Register this
972    // community's stream keys + start the challenge responder so the fetch below
973    // (whose REQ triggers the relay's AUTH challenge) reads the plane after auth.
974    super::streamauth::prime(&community);
975
976    // Authenticity = the owner's GENESIS metadata edition (vsk-0, `eid ==
977    // community_id`) at the root-derived control plane. The genesis eid pins it to
978    // THIS community, and it lives ONLY under the real root — so a forged root can't
979    // produce one: an edition's seal carries no community binding, but another
980    // community's genesis has a different eid, and this community's own genesis is
981    // unreadable without its real root (which the forger lacks). ("Any owner edition"
982    // is NOT sound: an owner sig from any co-owned community, rewrapped onto the fake
983    // plane, would pass — reopening the eclipse.) The residual — a T-member replaying
984    // T's genesis onto a fake root to MITM another T-joiner — is closed only by
985    // binding the root into community_id (protocol, deferred).
986    //
987    // Seed `until` with a FAR-FUTURE constant (NOT now-based): `until.is_some()` takes
988    // the transport's AUTHORITATIVE drain-ALL-relays path (an open `until` returns only
989    // a fast relay's partial window and misses a genesis on a lagging relay — routine
990    // over Tor), while a constant beyond any real created_at clips NOTHING — so neither
991    // a clock-skewed future-dated genesis nor a >1h-slow-clock joiner is excluded (a
992    // now-based bound could clip either). Break on an EMPTY page (a short page is a
993    // relay cap). A forged root walks to exhaustion and rejects; a flood/deep plane
994    // that buries the genesis past the walk is the deferred protocol residual.
995    const PAGE: usize = 500;
996    const MAX_PAGES: usize = 4;
997    const FAR_FUTURE_SECS: u64 = 4_102_444_800; // ~year 2100 — above any real edition, safe as a relay `until`.
998    let mut editions: Vec<ParsedEdition> = Vec::new();
999    let mut found_genesis = false;
1000    let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1001    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1002    for _ in 0..MAX_PAGES {
1003        let query = Query {
1004            kinds: vec![stream::KIND_WRAP],
1005            authors: vec![control_pk.clone()],
1006            until,
1007            limit: Some(PAGE),
1008            ..Default::default()
1009        };
1010        let wraps = transport.fetch(&query, &community.relays).await?;
1011        // INCLUSIVE `until` + wrap-id dedup: a `-1` step can skip same-second
1012        // siblings at a page boundary (and the genesis with them); re-served
1013        // boundary events are free, and no-new-events means exhausted.
1014        let mut oldest = u64::MAX;
1015        let mut fresh = 0usize;
1016        for w in &wraps {
1017            if !seen_wraps.insert(w.id) {
1018                continue;
1019            }
1020            fresh += 1;
1021            oldest = oldest.min(w.created_at.as_secs());
1022            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1023                if ed.author == owner {
1024                    if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1025                        found_genesis = true;
1026                    }
1027                    editions.push(ed);
1028                }
1029            }
1030        }
1031        if found_genesis || fresh == 0 {
1032            break; // authenticated (the owner genesis), or the relay is exhausted.
1033        }
1034        until = Some(oldest);
1035    }
1036    if !found_genesis {
1037        return Err(
1038            "could not verify this community from its relays (the invite may be forged, the relays are unreachable, or the control plane is being flooded); not joining"
1039                .to_string(),
1040        );
1041    }
1042    // Join-time reconcile: the joiner holds no floors yet (empty map → bootstrap per
1043    // entity). The heads this fold verified are returned for the caller to SEED as
1044    // the initial floor once the community row is saved — without that, the first
1045    // post-join follow would bootstrap floor-less and could persist a state BELOW
1046    // what this join already verified and showed.
1047    // Join-time reconcile folds only the owner's editions (genesis-authenticated
1048    // above), and the owner is supreme — so owner-only authority suffices. The full
1049    // roster (admins) folds on the first post-join follow_control.
1050    let empty_floors = Floors::new();
1051    let authority = AuthoritySet::owner_only();
1052    let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1053    Ok((fold.updated.unwrap_or(community), fold.heads))
1054}
1055
1056/// Accept a Direct Invite: unwrap the 3313 giftwrap (Schnorr-verifying the seal),
1057/// then run the shared accept path. The recipient's consent IS this call. No
1058/// network await precedes the accept, so the guard captured here suffices.
1059pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1060    let session = SessionGuard::capture();
1061    let me = local_keys()?;
1062    let (inviter, bundle) = invite::unwrap_direct_invite(wrap, &me).map_err(|e| e.to_string())?;
1063    accept_bundle(transport, &session, &bundle, Some(inviter)).await
1064}
1065
1066/// Accept a PARKED Direct Invite from its stored bundle JSON (the wrap was already
1067/// unwrapped + owner-verified at park time). Re-parses through the same fail-closed
1068/// bundle validation, then runs the shared accept path (which re-verifies the owner
1069/// root over the network). `inviter_hex` is the parked seal signer, for Guestbook
1070/// Join attribution.
1071pub async fn accept_parked_invite<T: Transport + ?Sized>(
1072    transport: &T,
1073    bundle_json: &str,
1074    inviter_hex: Option<&str>,
1075) -> Result<CommunityV2, String> {
1076    let session = SessionGuard::capture();
1077    let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1078    let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1079    accept_bundle(transport, &session, &bundle, invited_by).await
1080}
1081
1082/// Fetch + decrypt the newest Live bundle at a public link's coordinate
1083/// (`(33301, link_signer, "")`). **Revocation is authoritative-if-present**: if
1084/// ANY signer-valid tombstone is among the fetched events, refuse — never trust
1085/// fetch ordering (a cross-relay union has no global newest-first sort, so a
1086/// stale Live could otherwise win a partial-propagation race). Otherwise pick
1087/// the newest valid Live by `created_at`. Read-only.
1088pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1089    let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1090    // NO `#d` filter, even though the coordinate's `d` is empty (CORD-05 §2). Relays disagree on
1091    // indexing an empty tag value: some answer the REQ and then never EOSE, so the fetch burns its
1092    // whole union grace on every invite. The per-link signer pins the coordinate on its own (it
1093    // signs nothing else), and `parse_bundle_event` re-checks the empty `d` locally.
1094    let query = Query {
1095        kinds: vec![super::kind::INVITE_BUNDLE],
1096        authors: vec![parsed.link_signer.to_hex()],
1097        ..Default::default()
1098    };
1099    let relays = if parsed.bootstrap_relays.is_empty() {
1100        invite::stock_relays()
1101    } else {
1102        parsed.bootstrap_relays.clone()
1103    };
1104    let events = transport.fetch(&query, &relays).await?;
1105    let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1106
1107    // Scan EVERY event: a tombstone beats a Live unconditionally (order-independent).
1108    let mut newest_live: Option<(u64, CommunityInvite)> = None;
1109    for event in &events {
1110        match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1111            Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1112            Ok(invite::BundleState::Live(bundle)) => {
1113                let at = event.created_at.as_secs();
1114                if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1115                    newest_live = Some((at, *bundle));
1116                }
1117            }
1118            Err(_) => {} // a foreign/garbage event at the coordinate — ignore.
1119        }
1120    }
1121    newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1122}
1123
1124/// The most recent owner-root verification a PREVIEW completed, handed to a join
1125/// so accepting seconds later doesn't re-walk the control plane. Single-slot,
1126/// short-lived, session-guarded, and keyed on `(community_id, community_root)` —
1127/// a different delivered root never matches. The join's own bundle re-fetch is
1128/// untouched, so the revocation gate always runs live.
1129struct VerifiedPreview {
1130    session: SessionGuard,
1131    at: std::time::Instant,
1132    community_id: [u8; 32],
1133    community_root: [u8; 32],
1134    folded: CommunityV2,
1135    heads: Vec<FoldedHead>,
1136}
1137static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
1138const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
1139
1140/// Read-only rich preview of a public link: the decrypted bundle plus the LATEST
1141/// display metadata folded live from the Control Plane (a v2 bundle deliberately
1142/// carries no icon — the fold is the authority). Owner-root verification rides
1143/// the fold, so a forged-root link can't render a convincing preview; on a
1144/// fold/transport failure the bundle snapshot is the fallback. Nothing persists
1145/// — the caller hasn't joined.
1146pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1147    let bundle = fetch_public_bundle(transport, url).await?;
1148    preview_bundle(transport, &bundle).await
1149}
1150
1151/// The fold half of [`preview_public_link`], over an already-fetched bundle. Split out so a caller
1152/// that only needs the community's IDENTITY can read it off the bundle (it is self-certifying) and
1153/// skip the Control-Plane walk entirely — the walk is the join gate, and `accept_public_link` runs
1154/// it again regardless.
1155pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
1156    let community = CommunityV2::from_bundle(bundle, 0)?;
1157    match verify_owner_root_and_reconcile(transport, community.clone()).await {
1158        Ok((folded, heads)) => {
1159            *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
1160                session: SessionGuard::capture(),
1161                at: std::time::Instant::now(),
1162                community_id: folded.id().0,
1163                community_root: folded.community_root,
1164                folded: folded.clone(),
1165                heads,
1166            });
1167            Ok(folded)
1168        }
1169        Err(_) => Ok(community),
1170    }
1171}
1172
1173/// Accept a public invite link: fetch its bundle (revocation-aware) and join.
1174pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1175    // Capture BEFORE the network fetch so the join's is_valid() gate straddles it.
1176    let session = SessionGuard::capture();
1177    let bundle = fetch_public_bundle(transport, url).await?;
1178    if !session.is_valid() {
1179        return Err("account changed during join".to_string());
1180    }
1181    accept_bundle(transport, &session, &bundle, None).await
1182}
1183
1184/// Leave a community: publish a Guestbook Leave and tear down the local hold.
1185pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1186    let session = SessionGuard::capture();
1187    let me = local_keys()?;
1188    let at_ms = now_ms();
1189    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1190    let leave_rumor = guestbook::build_leave_rumor(me.public_key(), at_ms);
1191    if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor(&leave_rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000)) {
1192        let _ = transport.publish(&wrap, &community.relays).await;
1193    }
1194    if !session.is_valid() {
1195        return Err("account changed during leave".to_string());
1196    }
1197    // Tombstone the membership across devices (CORD-02 §8) BEFORE the local delete,
1198    // to the leaving community's own relays (it's about to be gone locally) —
1199    // best-effort.
1200    let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
1201    // The tombstone publish straddled an await — never delete from a swapped-in DB.
1202    if !session.is_valid() {
1203        return Err("account changed during leave".to_string());
1204    }
1205    crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1206    Ok(())
1207}
1208
1209/// Cooperative Kick (CORD-04 §6, Guestbook plane): name the target; every reader
1210/// honors it iff the signer holds KICK and strictly outranks them (the coalesce's
1211/// `can_kick`), so publishing without authority is inert. A kicked member may
1212/// rejoin with a fresh invite — cryptographic severance is the ban/refound path.
1213pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1214    let session = SessionGuard::capture();
1215    let me = local_keys()?;
1216    // Fast local pre-check; readers re-verify independently. The `vac` citation
1217    // rides with the deferred citation-completeness pass (owner needs none).
1218    let authority = fetch_authority(transport, community).await;
1219    let owner_hex = community.owner()?.to_hex();
1220    if !authority.roles.can_act_on_member(
1221        &me.public_key().to_hex(),
1222        Some(&owner_hex),
1223        &target.to_hex(),
1224        crate::community::roles::Permissions::KICK,
1225    ) {
1226        return Err("not authorized to kick this member".to_string());
1227    }
1228    let at_ms = now_ms();
1229    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1230    let rumor = guestbook::build_kick_rumor(me.public_key(), *target, None, at_ms);
1231    let (wrap, _) = guestbook::seal_guestbook_rumor(&rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000))
1232        .map_err(|e| e.to_string())?;
1233    if !session.is_valid() {
1234        return Err("account changed before send".to_string());
1235    }
1236    transport.publish(&wrap, &community.relays).await?;
1237    Ok(())
1238}
1239
1240/// A community's folded, delegation-authorized authority — the on-demand read
1241/// view (a paged control-plane fetch + fold, nothing persisted). `roles` is the
1242/// owner-seeded authorized roster (shared algebra with v1); `banned` the
1243/// enforced banlist. `floored`/`head_entities` let a writer detect a WITHHELD
1244/// entity (floored locally but no head folded) before replacing it blind.
1245pub struct AuthorityView {
1246    pub roles: crate::community::roles::CommunityRoles,
1247    pub banned: std::collections::BTreeSet<String>,
1248    /// Any authority entity's fold hit a floor gap (withheld / evicted link).
1249    pub gapped: bool,
1250    /// Entity hexes holding a persisted floor at this epoch (all vsk kinds).
1251    pub floored: std::collections::BTreeSet<String>,
1252    /// Authority entities (role/grant/banlist) that folded a head this fetch.
1253    pub head_entities: std::collections::BTreeSet<String>,
1254}
1255
1256/// Fetch + fold the community's current authority (CORD-04), paging older like
1257/// `follow_control` while the fold is gapped so a busy control plane can't push
1258/// the roster off the newest window. A fetch failure degrades fail-safe:
1259/// owner-only authority plus the PERSISTED banlist — nobody gains standing from
1260/// an outage, and a ban never lifts on withheld data.
1261pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1262    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1263    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1264        .unwrap_or_default()
1265        .into_iter()
1266        .filter(|(_, f)| f.0 == community.root_epoch.0)
1267        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1268        .collect();
1269    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1270
1271    let mut editions: Vec<ParsedEdition> = Vec::new();
1272    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1273    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1274    let mut oldest: Option<u64> = None;
1275    let mut until: Option<u64> = None;
1276    // Seed from an EMPTY fold, not owner_only(): a fold over zero editions yields
1277    // owner-only roles AND retains the PERSISTED banlist. So a first-page transport
1278    // error returns the stored bans (fail-safe), never an empty banlist that would
1279    // silently un-ban on withheld data.
1280    let mut a = fold_authority(community, &[], &floors);
1281    for _ in 0..FOLLOW_MAX_PAGES {
1282        let query = Query {
1283            kinds: vec![stream::KIND_WRAP],
1284            authors: vec![control.pk_hex()],
1285            until,
1286            limit: Some(FOLLOW_PAGE),
1287            ..Default::default()
1288        };
1289        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
1290        let mut fresh = 0usize;
1291        for w in &wraps {
1292            if !seen_wraps.insert(w.id) {
1293                continue;
1294            }
1295            fresh += 1;
1296            let at = w.created_at.as_secs();
1297            if oldest.is_none_or(|o| at < o) {
1298                oldest = Some(at);
1299            }
1300            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1301                if seen.insert(ed.inner_id) {
1302                    editions.push(ed);
1303                }
1304            }
1305        }
1306        a = fold_authority(community, &editions, &floors);
1307        if !a.gapped || fresh == 0 {
1308            break;
1309        }
1310        until = oldest;
1311    }
1312    AuthorityView {
1313        roles: a.roles,
1314        banned: a.banned,
1315        gapped: a.gapped,
1316        floored: floors.keys().cloned().collect(),
1317        head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
1318    }
1319}
1320
1321/// Page the Guestbook plane newest-to-oldest, stopping once a page's oldest wrap
1322/// falls below `since_secs` (everything older is already held) or the plane is
1323/// exhausted. Returns the parsed events at/after the window plus the newest wrap
1324/// time seen (the caller's next cursor; `since_secs` when nothing newer arrived).
1325///
1326/// PAGE bound rationale: a single 500-window silently drops a member whose Join
1327/// aged out (organic growth, or an insider flooding throwaway Joins), and
1328/// `refound_community` consumes the fold as its rekey recipient set — a dropped
1329/// member is SEVERED. Beyond this depth a community needs sharding (documented);
1330/// the granted-member union in [`fold_members`] is the consensus-complete
1331/// backstop regardless of Guestbook depth.
1332async fn fetch_guestbook_events<T: Transport + ?Sized>(
1333    transport: &T,
1334    community: &CommunityV2,
1335    since_secs: u64,
1336) -> Result<(Vec<guestbook::GuestbookEvent>, u64), String> {
1337    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1338    const GB_PAGE: usize = 500;
1339    const GB_MAX_PAGES: usize = 12;
1340    let mut events = Vec::new();
1341    let mut newest: u64 = since_secs;
1342    let mut seen: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1343    let mut until: Option<u64> = None;
1344    let mut oldest: Option<u64> = None;
1345    for _ in 0..GB_MAX_PAGES {
1346        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_group.pk_hex()], until, limit: Some(GB_PAGE), ..Default::default() };
1347        let wraps = transport.fetch(&query, &community.relays).await?;
1348        let mut fresh = 0usize;
1349        for wrap in &wraps {
1350            if !seen.insert(wrap.id) {
1351                continue;
1352            }
1353            fresh += 1;
1354            let at = wrap.created_at.as_secs();
1355            if oldest.is_none_or(|o| at < o) {
1356                oldest = Some(at);
1357            }
1358            if at > newest {
1359                newest = at;
1360            }
1361            // Older than the cursor window — already held; skip the decrypt.
1362            if at < since_secs {
1363                continue;
1364            }
1365            if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
1366                if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
1367                    events.push(ev);
1368                }
1369            }
1370        }
1371        if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
1372            break;
1373        }
1374        match oldest {
1375            Some(o) if o > 0 => until = Some(o),
1376            _ => break,
1377        }
1378    }
1379    Ok((events, newest))
1380}
1381
1382/// The shared membership fold: coalesce Guestbook events under the community's
1383/// authority (owner-supreme kicks, refounder snapshots), union observed authors
1384/// plus every roster grantee, subtract the banlist, and pin the proven owner.
1385/// One implementation, so the live and stored reads can't drift.
1386fn fold_members(
1387    community: &CommunityV2,
1388    events: &[guestbook::GuestbookEvent],
1389    mut observed: std::collections::BTreeMap<PublicKey, u64>,
1390    roles: &crate::community::roles::CommunityRoles,
1391    banlist: &std::collections::BTreeSet<PublicKey>,
1392) -> Result<Vec<PublicKey>, String> {
1393    let owner = community.owner()?;
1394    let owner_hex = owner.to_hex();
1395
1396    // CONSENSUS-COMPLETE backstop: every member the folded roster GRANTS a role to
1397    // is provably a member (a Grant binds member_xonly, CORD-02 A.6) — count them
1398    // even if their Join aged out of the Guestbook entirely and they never posted.
1399    // This is what keeps a Refounding from severing a lurking admin. `observed`
1400    // carries them at ts 0 (presence, not recency); the banlist subtraction below
1401    // still removes a banned grantee whose grant wasn't yet stripped.
1402    for g in &roles.grants {
1403        if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
1404            observed.entry(pk).or_insert(0);
1405        }
1406    }
1407
1408    // Snapshot authority (CORD-02 §5): a refounding rolls `root_epoch` and re-seeds the
1409    // new epoch's Guestbook with a 3312 snapshot of the survivors. Refounding is OWNER-only,
1410    // so the owner is the refounder whose snapshot is honored — without this, every silent
1411    // survivor vanishes from the memberlist until they re-post. A genesis community
1412    // (root_epoch 0) has no refounder, hence no snapshot power.
1413    let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1414    // Kick authority (CORD-04 §6): the signer must hold KICK AND strictly outrank the
1415    // target (the owner is supreme; equal cannot kick equal).
1416    let can_kick = |actor: &PublicKey, target: &PublicKey| {
1417        roles.can_act_on_member(&actor.to_hex(), Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1418    };
1419    let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
1420    let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist);
1421    // The owner is a member by definition, independent of any fetched Join.
1422    if !banlist.contains(&owner) {
1423        members.insert(owner);
1424    }
1425    Ok(members.into_iter().collect())
1426}
1427
1428/// Catch the persisted Guestbook up from its stored cursor (a fresh hold seeds
1429/// from zero). The fetch straddles the network, so the session re-checks before
1430/// the store writes. Returns the events that were NEW to the store — the caller
1431/// surfaces them (presence lines) and refreshes on non-empty.
1432pub async fn sync_guestbook<T: Transport + ?Sized>(
1433    transport: &T,
1434    community: &CommunityV2,
1435    session: &SessionGuard,
1436) -> Result<Vec<guestbook::GuestbookEvent>, String> {
1437    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1438    let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1439    // Overlap one second so a same-second boundary event can't slip the cursor;
1440    // the rumor-id merge below dedups the re-fetched edge.
1441    let since = cursor.saturating_sub(1);
1442    let (fresh, newest) = fetch_guestbook_events(transport, community, since).await?;
1443    if !session.is_valid() {
1444        return Err("account changed during guestbook sync".to_string());
1445    }
1446    let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
1447    let mut added = Vec::new();
1448    for ev in fresh {
1449        if !known.contains(&ev.rumor_id) {
1450            events.push(ev.clone());
1451            added.push(ev);
1452        }
1453    }
1454    if !added.is_empty() || newest > cursor {
1455        crate::db::community::set_guestbook(&cid_hex, &events, newest.max(cursor))?;
1456    }
1457    Ok(added)
1458}
1459
1460/// Fold ONE live guestbook event into the store (the realtime path — no fetch).
1461/// Returns whether it was new.
1462pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
1463    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1464    let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1465    if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
1466        return Ok(false);
1467    }
1468    events.push(ev);
1469    crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
1470    Ok(true)
1471}
1472
1473/// The memberlist from LOCAL state only: the persisted Guestbook, plus locally
1474/// observed authors (the synced events DB), plus roster grantees, minus the
1475/// banlist. Instant and offline-correct; [`sync_guestbook`] (post-join, boot,
1476/// reconnect, live ingest) keeps the store current. The live [`memberlist`]
1477/// remains the authoritative walk — a refounding's rekey recipient set must
1478/// never trust a possibly-stale store.
1479pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
1480    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1481    let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1482    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
1483    for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
1484        if let Ok(pk) = PublicKey::parse(&npub) {
1485            observed.insert(pk, last_active_secs.saturating_mul(1000));
1486        }
1487    }
1488    let roles = crate::db::community::get_community_roles(&cid_hex)?;
1489    let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
1490        .unwrap_or_default()
1491        .iter()
1492        .filter_map(|h| PublicKey::from_hex(h).ok())
1493        .collect();
1494    fold_members(community, &events, observed, &roles, &banlist)
1495}
1496
1497/// Fold the Complete Memberlist from the Guestbook plane. The proven owner is
1498/// ALWAYS a member (derived from the self-certifying community_id — no network,
1499/// so a lost/evicted genesis Join can't drop them). Observed authors — anyone
1500/// seen publishing on a channel — are folded in FORWARD-only per CORD-02 §5, so a
1501/// member whose Join was lost still counts.
1502pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
1503    let (events, _newest) = fetch_guestbook_events(transport, community, 0).await?;
1504    // Observed authors: fold each held channel's recent authorship (real author +
1505    // newest ms), so a member who posted but whose Join was lost is still counted.
1506    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
1507    for ch in &community.channels {
1508        if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
1509            for f in &page {
1510                let e = observed.entry(f.event.opened().author).or_insert(0);
1511                *e = (*e).max(f.event.opened().at_ms);
1512            }
1513        }
1514    }
1515
1516    // Fold the Control Plane roster + banlist (CORD-04) for Kick authority and the
1517    // ban subtraction. A control fetch failure degrades to owner-only authority + no
1518    // bans (fail-open on availability is safe here: a Kick still needs a real signer,
1519    // and a missed ban only fails to HIDE, never to wrongly admit authority).
1520    let authority = fetch_authority(transport, community).await;
1521    // The authorized banlist, as pubkeys (a malformed hex entry is simply dropped).
1522    let banlist: std::collections::BTreeSet<PublicKey> =
1523        authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
1524    fold_members(community, &events, observed, &authority.roles, &banlist)
1525}
1526
1527// ── Dissolution (CORD-02 §9) ─────────────────────────────────────────────────
1528
1529/// Owner dissolution / "Delete Community" (CORD-02 §9): publish the terminal
1530/// tombstone at the dissolved plane (`community_id`-derived, epoch-free, so every
1531/// past or present member resolves the same grave and a Refounding can never strand
1532/// it). The tombstone's presence IS the state; only the owner's seal counts.
1533/// Irreversible — on success the local hold is sealed read-only.
1534pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1535    let session = SessionGuard::capture();
1536    let me = local_keys()?;
1537    if community.owner()? != me.public_key() {
1538        return Err("only the owner can dissolve a community".to_string());
1539    }
1540    let at = now_ms() / 1000;
1541    let rumor = super::dissolution::dissolved_tombstone_rumor(me.public_key(), community.id(), at);
1542    let wrap = super::dissolution::seal_dissolved(&rumor, community.id(), &me, Timestamp::from_secs(at)).map_err(|e| e.to_string())?;
1543    if !session.is_valid() {
1544        return Err("account changed during dissolve".to_string());
1545    }
1546    // Durable broadcast: death must propagate (a rekey racing a dissolution loses).
1547    transport.publish_durable(&wrap, &community.relays).await?;
1548    crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1549    Ok(())
1550}
1551
1552/// Whether a valid owner-signed dissolution tombstone exists for this community on
1553/// its relays (CORD-02 §9). A join refuses a dead community, and a live follow seals
1554/// on sight. Fail-OPEN on a fetch error (absence of proof is not death), but any
1555/// owner-verified tombstone found is authoritative.
1556pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1557    let group = super::derive::dissolved_group_key(community.id());
1558    let query = Query {
1559        kinds: vec![stream::KIND_WRAP],
1560        authors: vec![group.pk_hex()],
1561        limit: Some(20),
1562        ..Default::default()
1563    };
1564    let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
1565        return false;
1566    };
1567    wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
1568}
1569
1570// ── Refounding (CORD-06 §3) ──────────────────────────────────────────────────
1571
1572/// Owner/admin Refounding (CORD-06 §3): roll the `community_root` to
1573/// cryptographically remove `removed` from a Private community (a Ban's read-cut).
1574/// Compacts the Control Plane under the new root (re-wraps each head VERBATIM — the
1575/// inner owner/actor signatures survive, so no re-authoring), rekeys the base plus
1576/// every Private channel (each sealed under the PRIOR root, D2, so a base-fork loser
1577/// can still open them), and seeds the new epoch's Guestbook snapshot. Requires BAN.
1578///
1579/// **Acquire-before-commit:** the compaction is fetched + re-sealed BEFORE any
1580/// publish, and a head we can't fetch ABORTS with ZERO published state — so a
1581/// transient miss never strands a published rekey with a half-anchored plane.
1582pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
1583    let session = SessionGuard::capture();
1584    let cid = community.id();
1585    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1586    // Death wins every race: a dissolved community never re-founds (CORD-02 §9).
1587    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
1588        return Err("this community has been dissolved; it cannot be re-founded".to_string());
1589    }
1590    let me = local_keys()?;
1591    // Serialize with the follow worker for the whole rotation: the commit tail
1592    // whole-row-saves, and an unserialized concurrent follow could otherwise be
1593    // rolled back (or adopt a half-published sibling of this very rotation).
1594    let lock = super::realtime::follow_lock(cid);
1595    let _guard = lock.lock().await;
1596    // Reload the FRESHEST base state: a stale caller struct would address the rotation
1597    // under a superseded root (a base fork with no heal). The community_id is
1598    // self-certifying + stable, so re-loading by it is safe.
1599    let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
1600    let community = &fresh;
1601    let owner = community.owner()?;
1602
1603    // CORD-06 §Authority: a Refounding requires the BAN permission and the rotator
1604    // must strictly OUTRANK every removed target — the owner is supreme (BAN ⊂
1605    // owner). Mirrors the receive counterpart (`advance_scope::base_rotator_ok`)
1606    // and the banlist authority fold: any admin holding BAN may re-found, checked
1607    // against the folded Roster. Fail-closed — an empty/unauthorized roster leaves
1608    // only the owner able to re-found.
1609    {
1610        let owner_hex = owner.to_hex();
1611        let me_hex = me.public_key().to_hex();
1612        // Persisted (last-folded) roster — the receive side is authoritative, so
1613        // this is a belt-and-suspenders gate. Fail-closed: a stale/empty roster
1614        // collapses to owner-only, which can only OVER-restrict a fresh admin whose
1615        // grant hasn't folded into their own DB (the caller's ban flow folds control
1616        // first). It can never grant authority no one has.
1617        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
1618        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
1619        let authorized = me.public_key() == owner
1620            || (!banned.contains(&me_hex)
1621                && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
1622                && removed.iter().all(|t| {
1623                    roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
1624                }));
1625        if !authorized {
1626            return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
1627        }
1628    }
1629
1630    // Fold the current roster: the opened editions are reused for the compaction (their
1631    // seals re-wrap under the new epoch), and the roster gates which admin-authored
1632    // heads carry forward.
1633    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
1634        .into_iter()
1635        .filter(|(_, f)| f.0 == community.root_epoch.0)
1636        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1637        .collect();
1638    let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
1639    // Page the ENTIRE control plane, not just the newest window: the compaction MUST
1640    // carry EVERY committed (floored) entity to the new epoch, so a head buried under a
1641    // flood of newer editions (100 roles + 400 grants already exceeds one page) or a
1642    // head a relay withholds can't silently drop. CORD-06 §3 mandates aborting if the
1643    // Refounder cannot fold all Control Events — a dropped Banlist would unban a member
1644    // at the new epoch a fresh joiner bootstraps.
1645    let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
1646    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1647    let mut oldest: Option<u64> = None;
1648    let mut until: Option<u64> = None;
1649    for _ in 0..FOLLOW_MAX_PAGES {
1650        let query = Query {
1651            kinds: vec![stream::KIND_WRAP],
1652            authors: vec![current_control.pk_hex()],
1653            until,
1654            limit: Some(FOLLOW_PAGE),
1655            ..Default::default()
1656        };
1657        let wraps = transport.fetch(&query, &community.relays).await?;
1658        let mut fresh = 0usize;
1659        for w in &wraps {
1660            if !seen_wraps.insert(w.id) {
1661                continue;
1662            }
1663            fresh += 1;
1664            let at = w.created_at.as_secs();
1665            if oldest.is_none_or(|o| at < o) {
1666                oldest = Some(at);
1667            }
1668            if let Ok(parsed) = control::open_control_edition(w, &current_control) {
1669                opened.push(parsed);
1670            }
1671        }
1672        // Page until every committed entity has its editions in hand (raw coverage),
1673        // so the floor-driven compaction below can fold each head.
1674        let present: std::collections::HashSet<String> =
1675            opened.iter().map(|(e, _)| crate::simd::hex::bytes_to_hex_32(&e.entity_id)).collect();
1676        if floors.keys().all(|k| present.contains(k)) || fresh == 0 {
1677            break;
1678        }
1679        until = oldest;
1680    }
1681
1682    let prev_epoch = community.root_epoch;
1683    let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
1684    let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
1685    // Mint-or-REUSE the new root, keyed by (scope, new_epoch) and archived BEFORE any
1686    // publish: a retried Refounding re-delivers the SAME root at this epoch/address, so
1687    // it can't double-mint two roots a receiver's correlation dedup would collapse into
1688    // a permanent fork (CORD-06 §3 idempotency).
1689    let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
1690    let new_control = control_group_key(&new_root, cid, new_epoch);
1691    let at = now_ms();
1692    let at_secs = at / 1000;
1693
1694    // ACQUIRE + COVERAGE GATE (CORD-06 §3 MUST): re-wrap the head of EVERY committed
1695    // (floored) entity under the new epoch — FLOOR-driven, so nothing silently drops,
1696    // including entities the metadata/roster folds don't touch (the invite Registry
1697    // vsk-8, whose coordinate survives the rekey per CORD-05 §5). A floor whose head
1698    // can't be folded (buried past the pager / withheld) ABORTS before any publish.
1699    use std::collections::BTreeMap;
1700    let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
1701    for (i, (e, _)) in opened.iter().enumerate() {
1702        by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
1703    }
1704    let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
1705    for (floor_key, floor) in &floors {
1706        // Re-wrap the AUTHORIZED head — the exact edition the persisted floor commits to
1707        // (its self_hash). The floor advances ONLY to authorized heads (author-aware fold),
1708        // so matching it is authority-correct across EVERY entity type. `fold_head`'s
1709        // version-chain TIP is author-BLIND: a member can seal a forged higher-version
1710        // edition chaining onto the floor, which the tip would carry and honest folders
1711        // then DROP as unauthorized — silently suppressing that role/grant/banlist across
1712        // the refounding. Abort if the committed head isn't served (fail-closed).
1713        let head_idx = by_eid
1714            .get(floor_key)
1715            .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
1716        let Some(head_idx) = head_idx else {
1717            return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
1718        };
1719        let (head_ed, head_os) = &opened[head_idx];
1720        let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
1721        let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
1722        carried.push((h, rewrapped));
1723    }
1724    if !session.is_valid() {
1725        return Err("account changed during re-founding acquire".to_string());
1726    }
1727
1728    // Recipients: the current members minus `removed`, plus me (multi-device).
1729    let members = memberlist(transport, community).await?;
1730    let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
1731    let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
1732    if !recipients.iter().any(|p| *p == me.public_key()) {
1733        recipients.push(me.public_key());
1734    }
1735
1736    // Base rekey blobs (the new root to each recipient), sealed under the PRIOR root.
1737    let mut base_blobs = Vec::new();
1738    for r in &recipients {
1739        base_blobs.push(
1740            super::rekey::build_blob_local(me.secret_key(), &me.public_key().to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
1741                .map_err(|e| e.to_string())?,
1742        );
1743    }
1744    let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
1745    let base_chunks =
1746        super::rekey::build_rekey_chunks_local(&me, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs)
1747            .map_err(|e| e.to_string())?;
1748
1749    // Private-channel rekeys: each mints a fresh key at its next channel-epoch, sealed
1750    // under the PRIOR root (D2). Public channels ride the base — no per-channel rekey.
1751    let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
1752    let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
1753    for ch in &community.channels {
1754        let (Some(old_key), true) = (ch.key, ch.private) else { continue };
1755        let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
1756        // Mint-or-reuse per channel too, keyed by (channel_id, next epoch) — same
1757        // retry-idempotency as the base root.
1758        let ch_new_key = mint_or_reuse_rotation_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch_new_epoch.0)?;
1759        let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
1760        let mut ch_blobs = Vec::new();
1761        for r in &recipients {
1762            ch_blobs.push(
1763                super::rekey::build_blob_local(me.secret_key(), &me.public_key().to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
1764                    .map_err(|e| e.to_string())?,
1765            );
1766        }
1767        let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
1768        let ch_chunks = super::rekey::build_rekey_chunks_local(&me, &ch_group, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, ch.epoch, &ch_prev_commit, &ch_blobs, at_secs)
1769            .map_err(|e| e.to_string())?;
1770        channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
1771        channel_chunk_sets.push(ch_chunks);
1772    }
1773    if !session.is_valid() {
1774        return Err("account changed during re-founding prepare".to_string());
1775    }
1776
1777    // COMMIT (durable publishes only — all fetching is done). Base rekey first
1778    // (delivers the new root), then channel rekeys, then the compacted control.
1779    for c in &base_chunks {
1780        transport.publish_durable(c, &community.relays).await?;
1781    }
1782    for set in &channel_chunk_sets {
1783        for c in set {
1784            transport.publish_durable(c, &community.relays).await?;
1785        }
1786    }
1787    for (_, wrap) in &carried {
1788        transport.publish_durable(wrap, &community.relays).await?;
1789    }
1790    // Guestbook snapshot at the new epoch — best-effort (a Refounding succeeds without
1791    // it; an omitted member heals by publishing their own Join).
1792    let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
1793    let snap_id = crate::community::random_32();
1794    for rumor in guestbook::build_snapshot_rumors(me.public_key(), &recipients, snap_id, at) {
1795        if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor(&rumor, &gb_group, &me, Timestamp::from_secs(at_secs)) {
1796            let _ = transport.publish(&wrap, &community.relays).await;
1797        }
1798    }
1799
1800    // COMMIT locally, only now that the new root + compacted plane are on relays.
1801    if !session.is_valid() {
1802        return Err("account changed during re-founding commit".to_string());
1803    }
1804    if crate::db::community::community_protocol(cid)?.is_none() {
1805        return Ok(community.clone()); // left/deleted mid-rotation — don't resurrect.
1806    }
1807    // Save the new root/epoch + rekeyed channel keys in ONE tx FIRST, so a crash can
1808    // never leave the base root advanced while the channel keys lag (which would
1809    // re-derive the channel rekey address under the wrong root and orphan them).
1810    let mut updated = community.clone();
1811    updated.community_root = new_root;
1812    updated.root_epoch = new_epoch;
1813    for (id, key, ep) in &channel_updates {
1814        if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
1815            c.key = Some(*key);
1816            c.epoch = *ep;
1817        }
1818    }
1819    crate::db::community::save_community_v2(&updated)?;
1820    // Archive the new epoch key + confirm the monotonic base head (the root was already
1821    // archived by mint_or_reuse, so this is idempotent). Record the carried heads at
1822    // the NEW epoch; if a crash skips this, the epoch-filtered floors bootstrap the
1823    // compacted control on the next follow, so they self-heal.
1824    crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
1825    for (h, _) in &carried {
1826        crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
1827    }
1828    // Refresh any live public links so their bundles carry the NEW root behind the
1829    // same URL (a link shared once survives the rotation, CORD-05 §2). Idempotent,
1830    // so retry a transient failure — a stranded link lands a new joiner on the dead
1831    // pre-refound epoch, and there's no other trigger to heal it before the next
1832    // refounding. A persistent failure is logged (refound already succeeded).
1833    for attempt in 0..3u8 {
1834        match refresh_public_links(transport, &updated).await {
1835            Ok(()) => break,
1836            Err(_) if !session.is_valid() => break, // swapped — stop touching this account
1837            Err(e) if attempt == 2 => {
1838                crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
1839            }
1840            Err(_) => continue,
1841        }
1842    }
1843    Ok(updated)
1844}
1845
1846/// Mint a fresh 32-byte rotation key for `(scope, new_epoch)`, or REUSE the one
1847/// already archived from a prior (aborted) attempt — so a retried Refounding re-
1848/// delivers the SAME key at the same epoch/address instead of double-minting two roots
1849/// a receiver's correlation dedup would collapse into a permanent fork (CORD-06 §3
1850/// idempotency). Archived BEFORE the first publish; `scope` is the all-zero server-root
1851/// sentinel for a base rotation, else the channel_id hex.
1852fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
1853    if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
1854        return Ok(existing);
1855    }
1856    let fresh = crate::community::random_32();
1857    crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
1858    Ok(fresh)
1859}
1860
1861// ── The Community List (kind 13302, CORD-02 §8) ──────────────────────────────
1862
1863/// This community's MEMBERSHIP subset for the 13302 list (CORD-02 §8): never the
1864/// icon (a rehydrating device folds it from the Control Plane), never the link
1865/// fields. Only PRIVATE channel keys ride — public channels derive from the root.
1866fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
1867    let hex = crate::simd::hex::bytes_to_hex_32;
1868    let channels = community
1869        .channels
1870        .iter()
1871        .filter(|c| c.private)
1872        .filter_map(|c| {
1873            c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: hex(&k), epoch: c.epoch.0, name: c.name.clone() })
1874        })
1875        .collect();
1876    super::list::JoinMaterial {
1877        community_id: hex(&community.identity.community_id.0),
1878        owner: hex(&community.identity.owner_xonly),
1879        owner_salt: hex(&community.identity.owner_salt),
1880        community_root: hex(&community.community_root),
1881        root_epoch: community.root_epoch.0,
1882        channels,
1883        relays: community.relays.clone(),
1884        name: community.name.clone(),
1885        extra: Default::default(),
1886    }
1887}
1888
1889/// Rebuild an invite bundle from list join material, for a cross-device rehydrate
1890/// (the material IS the membership subset of a bundle). The owner root is still
1891/// verified over the network before the community is trusted (accept_bundle).
1892fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
1893    let channels = jm
1894        .channels
1895        .iter()
1896        .map(|c| invite::ChannelGrant { id: c.id.clone(), key: c.key.clone(), epoch: c.epoch, name: c.name.clone() })
1897        .collect();
1898    CommunityInvite {
1899        community_id: jm.community_id.clone(),
1900        owner: jm.owner.clone(),
1901        owner_salt: jm.owner_salt.clone(),
1902        community_root: jm.community_root.clone(),
1903        root_epoch: jm.root_epoch,
1904        channels,
1905        relays: jm.relays.clone(),
1906        name: jm.name.clone(),
1907        icon: None,
1908        expires_at: None,
1909        creator_npub: None,
1910        label: None,
1911        extra: Default::default(),
1912    }
1913}
1914
1915/// The union of every held v2 community's relays — where this account's 13302 list
1916/// lives (a fresh device that opens any held community reaches the same set).
1917fn held_v2_relays() -> Vec<String> {
1918    let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1919    if let Ok(ids) = crate::db::community::list_community_ids() {
1920        for id in ids {
1921            if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
1922                if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
1923                    set.extend(c.relays);
1924                }
1925            }
1926        }
1927    }
1928    set.into_iter().collect()
1929}
1930
1931/// Fetch this account's own 13302 Community List from `relays` (the newest wins;
1932/// a decrypt/parse failure is "no news", never a clobber of the local mirror).
1933/// Fetch this account's newest 13302 list. `Err` = the transport FAILED (a caller
1934/// must NOT drive a replaceable-event write from a failed read — it would clobber
1935/// the live list); `Ok(None)` = genuinely no list yet; `Ok(Some)` = the list.
1936async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
1937    let me = local_keys()?;
1938    let query = Query {
1939        kinds: vec![super::kind::COMMUNITY_LIST],
1940        authors: vec![me.public_key().to_hex()],
1941        limit: Some(4),
1942        ..Default::default()
1943    };
1944    let events = transport.fetch(&query, relays).await?;
1945    Ok(events
1946        .into_iter()
1947        .filter_map(|e| super::list::parse_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
1948        .max_by_key(|(at, _)| *at)
1949        .map(|(_, l)| l))
1950}
1951
1952/// Rebuild this account's 13302 from its held v2 communities, MERGE with the remote
1953/// copy (preserving tombstones, other-device entries, unknown fields), and publish.
1954/// `just_joined` is the community THIS call is recording a create/join for — the
1955/// ONLY community whose entry is (re)stamped `now`, so it beats any prior tombstone
1956/// (a deliberate re-join resurrects). Every OTHER held community that the remote
1957/// has tombstoned is left tombstoned (a sibling device's leave is NOT undone just
1958/// because we joined something else — the W1 resurrection hole). Idempotent;
1959/// best-effort — a list-publish failure never fails the membership change itself.
1960pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<(), String> {
1961    let session = SessionGuard::capture();
1962    let me = local_keys()?;
1963    let relays = held_v2_relays();
1964    if relays.is_empty() {
1965        return Ok(()); // nothing held → nothing to sync
1966    }
1967    // A FAILED remote fetch must not drive this replaceable-event write: publishing
1968    // a list built without the remote seeds would drop older-epoch backfill anchors
1969    // and re-stamp add-times (the W2 seed-regression + a resurrection window).
1970    let remote = match fetch_community_list(transport, &relays).await {
1971        Ok(r) => r.unwrap_or_default(),
1972        Err(_) => return Ok(()),
1973    };
1974    let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
1975    let now = now_ms();
1976    let mut local = super::list::CommunityList::default();
1977    for id in crate::db::community::list_community_ids()? {
1978        if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
1979            continue;
1980        }
1981        let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
1982        let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
1983        let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
1984        // A held community the remote has tombstoned (a sibling device left it) that
1985        // we are NOT currently (re)joining stays LEFT — don't re-add it, or joining a
1986        // different community would silently undo the leave everywhere.
1987        if !is_join && !remote.is_live(&cid_hex) && remote.tombstones.iter().any(|t| t.community_id == cid_hex) {
1988            continue;
1989        }
1990        // Keep an already-live entry's add time (no churn); the joined community (or a
1991        // genuinely-new one) stamps `now` so a re-join beats a stale tombstone.
1992        let added_at = if remote.is_live(&cid_hex) && !is_join {
1993            remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
1994        } else {
1995            now
1996        };
1997        let jm = join_material(&c);
1998        local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
1999    }
2000    let merged = remote.merge(&local);
2001    merged.assert_fits().map_err(|e| e.to_string())?;
2002    let event = super::list::build_list_event(&me, &merged).map_err(|e| e.to_string())?;
2003    if !session.is_valid() {
2004        return Err("account changed during community-list publish".to_string());
2005    }
2006    transport.publish(&event, &relays).await
2007}
2008
2009/// Record a permanent leave tombstone for `community_id` in the 13302, published to
2010/// `relays` (the leaving community's own, since it's about to be deleted locally).
2011async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
2012    let me = local_keys()?;
2013    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
2014    // A failed fetch here would drop other communities' entries (only the
2015    // tombstone would survive); preserve them by bailing — the leave re-records
2016    // on the next attempt, and the local teardown already happened.
2017    let mut doc = match fetch_community_list(transport, relays).await {
2018        Ok(d) => d.unwrap_or_default(),
2019        Err(e) => return Err(e),
2020    };
2021    let now = now_ms();
2022    doc.tombstones.retain(|t| t.community_id != cid_hex);
2023    doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
2024    doc.assert_fits().map_err(|e| e.to_string())?;
2025    let event = super::list::build_list_event(&me, &doc).map_err(|e| e.to_string())?;
2026    transport.publish(&event, relays).await
2027}
2028
2029/// Sync memberships from the 13302 across devices: fetch this account's list from
2030/// `bootstrap_relays` (its held communities' relays plus any caller-supplied set for
2031/// a fresh device), and JOIN every live entry not already held — reconstructing the
2032/// community from its join material and re-verifying the owner root. Returns the
2033/// newly-rehydrated communities (so the caller can subscribe + notify).
2034pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<Vec<CommunityV2>, String> {
2035    let session = SessionGuard::capture();
2036    let mut relays = held_v2_relays();
2037    relays.extend(bootstrap_relays.iter().cloned());
2038    relays.sort();
2039    relays.dedup();
2040    if relays.is_empty() {
2041        return Ok(vec![]);
2042    }
2043    let list = match fetch_community_list(transport, &relays).await {
2044        Ok(Some(l)) => l,
2045        Ok(None) | Err(_) => return Ok(vec![]),
2046    };
2047    // Receive-side teardown (the counterpart to the republish tombstone guard):
2048    // a community this device still holds but the synced list shows TOMBSTONED (a
2049    // sibling device left it) and NOT live gets torn down here, so a leave on one
2050    // device propagates to the others. A re-join would have re-added it live
2051    // (beating the tombstone), so is_live short-circuits the honest case.
2052    for t in &list.tombstones {
2053        if list.is_live(&t.community_id) {
2054            continue;
2055        }
2056        let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
2057        let id = crate::community::CommunityId(cid);
2058        if crate::db::community::load_community_v2(&id).ok().flatten().is_none() {
2059            continue; // not held — nothing to tear down
2060        }
2061        if !session.is_valid() {
2062            return Err("account changed during community-list sync".to_string());
2063        }
2064        let _ = crate::db::community::delete_community(&t.community_id);
2065    }
2066    let mut joined = Vec::new();
2067    for entry in list.live_entries() {
2068        let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
2069        if crate::db::community::load_community_v2(&crate::community::CommunityId(cid)).ok().flatten().is_some() {
2070            continue; // already held
2071        }
2072        if !session.is_valid() {
2073            return Err("account changed during community-list sync".to_string());
2074        }
2075        // The material IS a bundle; accept_bundle re-verifies the owner root, saves,
2076        // seeds floors, and announces our Join (idempotent for an existing member).
2077        let bundle = material_to_invite(&entry.current);
2078        if let Ok(community) = accept_bundle(transport, &session, &bundle, None).await {
2079            joined.push(community);
2080        }
2081    }
2082    Ok(joined)
2083}
2084
2085// ── Control edition authoring (CORD-04 roles / CORD-02 §6 / CORD-03 §2) ──────
2086
2087/// Publish one control edition (a role, grant, banlist, community-metadata, or
2088/// channel-metadata edit) at the next version for its entity, chaining `prev` from
2089/// our held head, and advance our local floor. Authority is enforced by every
2090/// reader's roster fold (CORD-04 §5: authority is rejection, not prevention), so this
2091/// requires only a valid local signer; a well-behaved client checks its own rank
2092/// first, but a reader drops an unauthorized edition regardless.
2093async fn publish_control_edition<T: Transport + ?Sized>(
2094    transport: &T,
2095    community: &CommunityV2,
2096    session: &SessionGuard,
2097    vsk: &str,
2098    entity_id: &[u8; 32],
2099    content: &str,
2100    citation: Option<&crate::community::edition::AuthorityCitation>,
2101) -> Result<(), String> {
2102    let me = local_keys()?;
2103    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
2104    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2105    let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
2106    let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
2107        Some((v, h)) => (v + 1, Some(h)),
2108        None => (1, None),
2109    };
2110    let at = now_ms() / 1000;
2111    let rumor = control::build_edition_rumor(me.public_key(), vsk, entity_id, version, prev.as_ref(), content, at, citation);
2112    let (wrap, _) = control::seal_control_edition(&rumor, &control, &me, Timestamp::from_secs(at)).map_err(|e| e.to_string())?;
2113    if !session.is_valid() {
2114        return Err("account changed before control publish".to_string());
2115    }
2116    transport.publish(&wrap, &community.relays).await?;
2117    // Advance our own floor so a follow-up edit chains from this head and refuse-
2118    // downgrade holds; open our own wrap to recover the self_hash + inner_id.
2119    // Re-check the session AFTER the publish await: a swap mid-publish means the
2120    // pool now points at another account's DB — skipping is safe (the next own
2121    // edit rebuilds the same head from the relay's copy).
2122    if !session.is_valid() {
2123        return Ok(());
2124    }
2125    if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
2126        crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
2127    }
2128    Ok(())
2129}
2130
2131/// Create or edit a Role (vsk 1, CORD-04 §2). `role.role_id` is the coordinate; a
2132/// rename or permission change is a versioned edit of the same id. Gated on the
2133/// reader side by `MANAGE_ROLES` + outrank.
2134pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
2135    let session = SessionGuard::capture();
2136    super::roles::validate_role(role)?;
2137    let content = super::roles::role_content_json(role)?;
2138    let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
2139    publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content, None).await
2140}
2141
2142/// Grant or revoke a member's Roles (vsk 3, CORD-04 §2). Empty `role_ids` is a
2143/// revoke. Gated on the reader side by `MANAGE_ROLES` + outrank of every role + the
2144/// member.
2145pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
2146    let session = SessionGuard::capture();
2147    let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
2148    let content = super::roles::grant_content_json(&grant)?;
2149    let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
2150    publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content, None).await
2151}
2152
2153/// The community's @admin role id: the folded Server-scope ADMIN_ALL role when one
2154/// exists, else (with `create_if_missing`) a DETERMINISTIC mint — the same id on
2155/// every device, so concurrent grants converge as editions of ONE entity instead
2156/// of forking two Admin roles.
2157pub async fn ensure_admin_role<T: Transport + ?Sized>(
2158    transport: &T,
2159    community: &CommunityV2,
2160    view: &AuthorityView,
2161    create_if_missing: bool,
2162) -> Result<Option<String>, String> {
2163    use crate::community::roles::{Permissions, Role, RoleScope};
2164    if let Some(r) = view
2165        .roles
2166        .roles
2167        .iter()
2168        .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_ALL))
2169    {
2170        return Ok(Some(r.role_id.clone()));
2171    }
2172    if !create_if_missing {
2173        return Ok(None);
2174    }
2175    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2176    let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
2177    set_role(transport, community, &Role::admin(role_id.clone())).await?;
2178    Ok(Some(role_id))
2179}
2180
2181/// Grant the @admin role (minting it deterministically when absent), MERGED into
2182/// the member's existing grant — a grant entity replaces whole (CORD-04 §2), so a
2183/// blind push would erase their other roles. Owner-only: the position-1 Admin is
2184/// manageable only by position 0 (an equal never outranks it), and refusing
2185/// before any publish keeps an unauthorized edition of the DETERMINISTIC admin
2186/// entity from advancing this device's own floor onto a head readers reject.
2187pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
2188    // Guard spans the multi-page fetch below: a swap mid-fetch must not let the
2189    // downstream publish's own (post-swap) guard write account A's floor into B.
2190    let session = SessionGuard::capture();
2191    let me = local_keys()?;
2192    if me.public_key() != community.owner()? {
2193        return Err("only the community owner can grant @admin".to_string());
2194    }
2195    let view = fetch_authority(transport, community).await;
2196    if !session.is_valid() {
2197        return Err("account changed during grant".to_string());
2198    }
2199    let member_hex = member.to_hex();
2200    require_grant_head(community, &view, &member_hex)?;
2201    let role_id = ensure_admin_role(transport, community, &view, true)
2202        .await?
2203        .expect("create_if_missing yields an id");
2204    let mut role_ids = view
2205        .roles
2206        .grants
2207        .iter()
2208        .find(|g| g.member == member_hex)
2209        .map(|g| g.role_ids.clone())
2210        .unwrap_or_default();
2211    if role_ids.contains(&role_id) {
2212        return Ok(()); // already admin — don't bump the grant edition for nothing.
2213    }
2214    role_ids.push(role_id);
2215    grant_roles(transport, community, member, role_ids).await
2216}
2217
2218/// Strip the @admin role from the member's grant, preserving their other roles.
2219/// A no-op when they don't hold it. Owner-only, like [`grant_admin`].
2220pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
2221    let session = SessionGuard::capture();
2222    let me = local_keys()?;
2223    if me.public_key() != community.owner()? {
2224        return Err("only the community owner can revoke @admin".to_string());
2225    }
2226    let view = fetch_authority(transport, community).await;
2227    if !session.is_valid() {
2228        return Err("account changed during revoke".to_string());
2229    }
2230    let member_hex = member.to_hex();
2231    require_grant_head(community, &view, &member_hex)?;
2232    let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
2233        return Ok(()); // no admin role exists — nothing to revoke.
2234    };
2235    let mut role_ids = view
2236        .roles
2237        .grants
2238        .iter()
2239        .find(|g| g.member == member_hex)
2240        .map(|g| g.role_ids.clone())
2241        .unwrap_or_default();
2242    let before = role_ids.len();
2243    role_ids.retain(|r| r != &role_id);
2244    if role_ids.len() == before {
2245        return Ok(());
2246    }
2247    grant_roles(transport, community, member, role_ids).await
2248}
2249
2250/// A grant replaces whole — refuse the merge when this member's grant is FLOORED
2251/// locally but no head folded (withheld / evicted): a blind push at that point
2252/// would erase their other roles at a higher version.
2253fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
2254    let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
2255        return Err("malformed member key".to_string());
2256    };
2257    let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
2258    if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
2259        return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
2260    }
2261    Ok(())
2262}
2263
2264/// Replace the Banlist (vsk 4, CORD-04 §4) with `banned` (lowercase-hex npubs), the
2265/// whole list on every edit. Gated on the reader side by `BAN`.
2266pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
2267    let session = SessionGuard::capture();
2268    super::roles::validate_banlist(banned)?;
2269    let content = super::roles::banlist_content_json(banned)?;
2270    let eid = super::derive::banlist_locator(community.id());
2271    publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content, None).await
2272}
2273
2274/// Edit the community metadata (vsk 0, CORD-02 §6). Gated on the reader side by
2275/// `MANAGE_METADATA`.
2276pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
2277    let session = SessionGuard::capture();
2278    control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
2279    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
2280    publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content, None).await
2281}
2282
2283/// Add or edit a channel's metadata (vsk 2, CORD-03 §2). `channel_id` is the
2284/// coordinate. Gated on the reader side by `MANAGE_CHANNELS`.
2285pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
2286    let session = SessionGuard::capture();
2287    let me = local_keys()?;
2288    ensure_channel_manager(community, &me.public_key())?;
2289    // Public → private CONVERSION is a key rotation (CORD-03 §2) this build doesn't
2290    // mint yet — refuse the flag flip rather than publish an edition no reader can
2291    // key (members would keep posting on the root-derived plane, splitting the
2292    // channel). Private → public works (readers heal to the root derivation).
2293    if meta.private {
2294        if let Some(held) = community.channel(channel_id) {
2295            if !held.private {
2296                return Err("converting a public channel to private is not supported yet".to_string());
2297            }
2298        }
2299    }
2300    control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
2301    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
2302    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await
2303}
2304
2305/// The local mirror of the reader's `MANAGE_CHANNELS` fold gate (CORD-03 §2): the
2306/// owner, or a roster-authorized manager who isn't banned. Refusing BEFORE any
2307/// publish keeps an unauthorized device from advancing its own edition floor onto
2308/// a head every reader rejects (wedging its later, legitimately-authorized edits
2309/// behind a rejected chain).
2310fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
2311    let owner = community.owner()?;
2312    if *me == owner {
2313        return Ok(());
2314    }
2315    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2316    let me_hex = me.to_hex();
2317    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
2318        return Err("you are banned from this community".to_string());
2319    }
2320    let roster = crate::db::community::get_community_roles(&cid_hex)?;
2321    if roster.is_authorized(&me_hex, Some(&owner.to_hex()), crate::community::roles::Permissions::MANAGE_CHANNELS) {
2322        Ok(())
2323    } else {
2324        Err("managing channels here needs the MANAGE_CHANNELS permission".to_string())
2325    }
2326}
2327
2328/// Create a new PUBLIC channel (CORD-03 §2): mint a fresh id, publish its metadata
2329/// edition (vsk 2), and add it to the held community. A Public channel derives its Chat
2330/// Plane from the `community_root` (no per-channel key), so other members fold it in on
2331/// their next control follow with nothing to distribute. Returns the new channel id.
2332/// Reader-gated by `MANAGE_CHANNELS`.
2333pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
2334    let session = SessionGuard::capture();
2335    // Serialize with the follow worker: the save below writes the WHOLE community
2336    // row from this caller's struct, so an unserialized concurrent follow adopting
2337    // a rotation would be rolled back to a stale root (a deaf community).
2338    let lock = super::realtime::follow_lock(community.id());
2339    let _guard = lock.lock().await;
2340    let me = local_keys()?;
2341    ensure_channel_manager(community, &me.public_key())?;
2342    let channel_id = ChannelId(super::super::random_32());
2343    let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
2344    control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
2345    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2346    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2347    if !session.is_valid() {
2348        return Err("account changed during channel create".to_string());
2349    }
2350    // Add locally + persist so the creator can post immediately (peers fold it in).
2351    let mut updated = community.clone();
2352    updated.channels.push(ChannelV2 { id: channel_id, name: name.to_string(), private: false, key: None, epoch: updated.root_epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
2353    crate::db::community::save_community_v2(&updated)?;
2354    Ok(channel_id)
2355}
2356
2357/// Create a new PRIVATE channel (CORD-03 §2): mint a fresh id + an independent
2358/// random key at channel-epoch 1, deliver the key to every current member over the
2359/// rekey plane (CORD-06 §1), then announce the channel (vsk 2, `private`). Epoch 0
2360/// is the root generation ("the first privatisation is epoch 1"), so the delivery
2361/// commits its continuity to `(0, community_root)` — verifiable by every member and
2362/// bound to THIS community's root. The key ships BEFORE the announcement: an
2363/// aborted attempt leaves only an unannounced crate (invisible), and a retry mints
2364/// a fresh id, so there is no same-coordinate double-mint to fork on. Live public
2365/// links are refreshed so a joiner's bundle carries the key; a member who joins
2366/// through the stale-bundle window keys up at the channel's next rotation.
2367pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
2368    let session = SessionGuard::capture();
2369    // Serialize with the follow worker across the whole fetch→publish→save span
2370    // (the memberlist fetch is seconds long; an unserialized follow adopting a
2371    // rotation meanwhile would be rolled back by the whole-row save below).
2372    let lock = super::realtime::follow_lock(community.id());
2373    let _guard = lock.lock().await;
2374    let me = local_keys()?;
2375    ensure_channel_manager(community, &me.public_key())?;
2376    let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
2377    control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
2378    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2379
2380    let channel_id = ChannelId(super::super::random_32());
2381    let channel_key = super::super::random_32();
2382    let epoch = Epoch(1);
2383
2384    // Recipients: every current member, plus me (multi-device).
2385    let mut recipients = memberlist(transport, community).await?;
2386    if !recipients.iter().any(|p| *p == me.public_key()) {
2387        recipients.push(me.public_key());
2388    }
2389    let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
2390    let mut blobs = Vec::with_capacity(recipients.len());
2391    for r in &recipients {
2392        blobs.push(
2393            rekey::build_blob_local(me.secret_key(), &me.public_key().to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
2394                .map_err(|e| e.to_string())?,
2395        );
2396    }
2397    let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
2398    let at_secs = now_ms() / 1000;
2399    let chunks = rekey::build_rekey_chunks_local(&me, &group, RekeyScope::Channel(channel_id), epoch, Epoch(0), &prev_commit, &blobs, at_secs)
2400        .map_err(|e| e.to_string())?;
2401    if !session.is_valid() {
2402        return Err("account changed during channel create".to_string());
2403    }
2404    for c in &chunks {
2405        transport.publish_durable(c, &community.relays).await?;
2406    }
2407    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2408    if !session.is_valid() {
2409        return Err("account changed during channel create".to_string());
2410    }
2411    // A leave/delete raced the create: saving would resurrect the community row.
2412    if crate::db::community::community_protocol(community.id())?.is_none() {
2413        return Err("community removed during channel create".to_string());
2414    }
2415    let mut updated = community.clone();
2416    updated.channels.push(ChannelV2 { id: channel_id, name: name.to_string(), private: true, key: Some(channel_key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
2417    crate::db::community::save_community_v2(&updated)?;
2418    // Archive the epoch-1 key so this channel's history stays readable across its
2419    // future rotations (CORD-03 §3).
2420    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2421    crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
2422    // Future joiners are handed the key in their (refreshed) bundle, CORD-05 §1.
2423    let _ = refresh_public_links(transport, &updated).await;
2424    Ok(channel_id)
2425}
2426
2427/// Tombstone a channel (CORD-03 §2, `deleted: true`) + drop it locally. Reader-gated by
2428/// `MANAGE_CHANNELS`; the coordinate stays folded as a grave so peers hide it.
2429pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
2430    let session = SessionGuard::capture();
2431    // Whole-row save below — serialize with the follow worker (see create_*_channel).
2432    let lock = super::realtime::follow_lock(community.id());
2433    let _guard = lock.lock().await;
2434    let me = local_keys()?;
2435    ensure_channel_manager(community, &me.public_key())?;
2436    // The tombstone carries the FULL held document (deleted flag set): a strict
2437    // reader treats an edition as the entity, so even a deletion must not strip
2438    // fields it didn't touch (CORD-02 §6).
2439    let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
2440        name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
2441    });
2442    meta.deleted = Some(true);
2443    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2444    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2445    if !session.is_valid() {
2446        return Err("account changed during channel delete".to_string());
2447    }
2448    let mut updated = community.clone();
2449    updated.channels.retain(|c| c.id.0 != channel_id.0);
2450    crate::db::community::save_community_v2(&updated)?;
2451    Ok(())
2452}
2453
2454// ── Live control-follow (CORD-02 §6 / CORD-03 §2) ────────────────────────────
2455
2456/// Re-fold this community's Control Plane and apply the current metadata +
2457/// **public** channel set to the held community, persisting any change. Called
2458/// when a control-plane wrap arrives in realtime (a rename, a new channel, an
2459/// edited description) so a long-running bot tracks the community mid-session
2460/// instead of freezing at its join-time view.
2461///
2462/// **Authority (CORD-04 §5):** the roster (roles/grants/banlist) folds first into
2463/// the owner-seeded authorized set ([`fold_authority`]), then each metadata/channel
2464/// edition is eligible only if its signer CURRENTLY holds the entity's management
2465/// bit (`MANAGE_METADATA`/`MANAGE_CHANNELS`) — so an authorized admin's edits fold,
2466/// a demoted one's drop. The owner is supreme, proven by the self-certifying
2467/// community_id (no network trust).
2468///
2469/// **Private channels are skipped here:** a Private channel's Chat-Plane key is
2470/// delivered over the rekey plane (or an invite bundle), never derivable from a
2471/// control edition alone. A new Private channel therefore surfaces only once
2472/// [`follow_rekeys`] delivers its key. Public channels derive from the
2473/// community_root, so they fold in directly.
2474///
2475/// Returns the updated community iff something changed (so the caller can skip a
2476/// redundant re-subscribe + refresh notification).
2477pub async fn follow_control<T: Transport + ?Sized>(
2478    transport: &T,
2479    community: &CommunityV2,
2480    session: &SessionGuard,
2481) -> Result<Option<CommunityV2>, String> {
2482    community.owner()?; // fail fast if the community is somehow unproven.
2483    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
2484    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2485
2486    // Per-entity refuse-downgrade floors for the CURRENT epoch only. A head recorded
2487    // under a prior epoch is excluded, so that entity auto-bootstraps after a
2488    // Refounding (Armada accepts a compacted head across a dangling prev — matched).
2489    // A read error FAILS CLOSED: an empty map would silently re-open the rollback
2490    // window the floor exists to shut.
2491    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2492        .into_iter()
2493        .filter(|(_, f)| f.0 == community.root_epoch.0)
2494        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2495        .collect();
2496
2497    // Newest window first; page OLDER only while a tracking entity is gapped (its
2498    // floor link evicted from the window — H1/M8 refetch), bounded like the join
2499    // verifier. A withholding relay still converges to fail-closed after the cap.
2500    let mut editions: Vec<ParsedEdition> = Vec::new();
2501    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2502    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
2503    let mut oldest: Option<u64> = None;
2504    let mut until: Option<u64> = None;
2505    let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false };
2506    let mut authority = AuthoritySet::owner_only();
2507    for _ in 0..FOLLOW_MAX_PAGES {
2508        let query = Query {
2509            kinds: vec![stream::KIND_WRAP],
2510            authors: vec![control.pk_hex()],
2511            until,
2512            limit: Some(FOLLOW_PAGE),
2513            ..Default::default()
2514        };
2515        let wraps = transport.fetch(&query, &community.relays).await?;
2516        // The `until` cursor is INCLUSIVE (a `-1` step can skip same-second siblings
2517        // at a page boundary); the wrap-id dedup makes re-served boundary events
2518        // free, and a page with nothing new means the relay is exhausted.
2519        let mut fresh = 0usize;
2520        for w in &wraps {
2521            if !seen_wraps.insert(w.id) {
2522                continue;
2523            }
2524            fresh += 1;
2525            let at = w.created_at.as_secs();
2526            if oldest.is_none_or(|o| at < o) {
2527                oldest = Some(at);
2528            }
2529            // Open + seal-verify every edition; authority is resolved by the roster
2530            // fold (CORD-04 §5), not by a signer filter here — an admin's edits fold.
2531            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
2532                if seen.insert(ed.inner_id) {
2533                    editions.push(ed);
2534                }
2535            }
2536        }
2537        // Roster first (roles/grants/banlist → authorized set), then the authority-
2538        // gated metadata/channel fold over the same edition set.
2539        authority = fold_authority(community, &editions, &floors);
2540        fold = apply_control_fold(community, &editions, &floors, &authority);
2541        if !(fold.gapped || authority.gapped) || fresh == 0 {
2542            break;
2543        }
2544        until = oldest;
2545    }
2546
2547    // The fetches straddled awaits; a swap since the guard was captured must not
2548    // write account A's control state into B.
2549    if !session.is_valid() {
2550        return Err("account changed during control follow".to_string());
2551    }
2552    // A leave/delete raced this follow: writing now would resurrect the community
2553    // row and orphan floor rows past delete_community's wipe.
2554    if crate::db::community::community_protocol(community.id())?.is_none() {
2555        return Ok(None);
2556    }
2557    // Persist advanced floors BEFORE the state save (a failed floor write must not
2558    // let saved state outrun its floor), stamping the epoch this fold ran under —
2559    // not the row's write-time value, which a concurrent re-founding can bump. Both
2560    // the metadata/channel heads and the roster/banlist heads advance their floors;
2561    // run the advance (v+1) and same-version convergence (fork tiebreak) paths.
2562    for h in fold.heads.iter().chain(authority.heads.iter()) {
2563        crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, community.root_epoch.0)?;
2564        crate::db::community::converge_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, community.root_epoch.0)?;
2565    }
2566    // Persist the authorized banlist content (retained/withholding folds carry None,
2567    // so the stored banlist is left intact — an anti-roster never silently un-bans).
2568    if let Some((banned, version)) = &authority.banlist_persist {
2569        crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
2570    }
2571    // Persist the authorized roster so capabilities/roles stay sync LOCAL reads
2572    // (v1 parity: the passive follow folds, reads never fetch). Guarded like v1's
2573    // fetch path: only an aggregate built from roster editions at least as new as
2574    // the stored one may replace it — a withholding relay serving NO roster
2575    // editions folds an empty-but-ungapped aggregate (absence raises no gap flag),
2576    // and that must RETAIN the stored roster, never wipe standing.
2577    let newest_roster_at: i64 = editions
2578        .iter()
2579        .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
2580        .map(|e| e.created_at as i64)
2581        .max()
2582        .unwrap_or(0);
2583    // Completeness gate: the `gapped` flag only covers entities present in the window.
2584    // A role/grant floored on this device but with ZERO editions fetched (aged out of
2585    // the paging reach) folds absent yet raises no gap — persisting would silently drop
2586    // it. So if any CURRENTLY-STORED entity is floored but folded no head this round,
2587    // RETAIN. A real revoke still folds a head (see select_authorized), so it persists.
2588    let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2589    let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
2590    let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
2591        && stored.grants.iter().all(|g| {
2592            crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
2593                let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
2594                !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
2595            })
2596        });
2597    if !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
2598        crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
2599    }
2600    match fold.updated {
2601        Some(u) => {
2602            crate::db::community::save_community_v2(&u)?;
2603            Ok(Some(u))
2604        }
2605        None => Ok(None),
2606    }
2607}
2608
2609/// Control-follow paging bounds: enough depth to re-anchor a long-offline floor
2610/// (H1/M8 refetch) without letting a flooding relay stall the follow queue.
2611const FOLLOW_MAX_PAGES: usize = 4;
2612const FOLLOW_PAGE: usize = 500;
2613
2614/// A folded control head to persist as the per-entity refuse-downgrade floor.
2615#[derive(Clone)]
2616struct FoldedHead {
2617    entity_hex: String,
2618    version: u64,
2619    self_hash: [u8; 32],
2620    inner_id: [u8; 32],
2621}
2622
2623/// The outcome of a floor-aware control fold: the updated community (if content
2624/// changed), the heads to persist as the new floor (returned even when content is
2625/// unchanged, so the floor still seeds/advances), and whether any TRACKING entity
2626/// hit an unresolvable gap — the caller's signal to page older history and re-fold
2627/// (CORD-04 H1/M8's refetch).
2628struct ControlFold {
2629    updated: Option<CommunityV2>,
2630    heads: Vec<FoldedHead>,
2631    gapped: bool,
2632}
2633
2634/// Per-entity floor: `(version, self_hash, inner_id)` of the committed head.
2635type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
2636
2637/// Fold owner-authored control editions into an updated community using the
2638/// PERSISTED per-entity version floor (refuse-downgrade). Per entity, fold with
2639/// [`version::fold`]`(floor, floor_hash)`:
2640///   - ANCHORED: adopt the chain-verified head. A `gap` ABOVE it (withheld middles)
2641///     doesn't block the verified prefix — refuse-downgrade holds for everything
2642///     applied — but flags `gapped` so the caller pages for the rest.
2643///   - UNANCHORED under a held floor: one legitimate cause is a same-version owner
2644///     fork AT the floor whose deterministic winner (lower inner id; a NULL held id
2645///     is always replaceable, mirroring v1's `decide()`) isn't our held edition —
2646///     the floor CONVERGES to the winner and the chain re-anchors on it, so every
2647///     client lands on the same head where a hash-strict floor would wedge forever.
2648///     Anything else is withholding → fail closed + `gapped`.
2649///   - BOOTSTRAPPING (`floor == 0` — a fresh joiner, or a fresh epoch after a
2650///     Refounding, since the caller epoch-filters the floor) takes the highest
2651///     signed head (author already owner-filtered).
2652/// This matches CORD-04 §1 and mirrors v1's `fold_roster`. Epoch-filtering makes a
2653/// compaction at a new epoch auto-bootstrap, converging with Armada's acceptance of
2654/// a compacted head across a dangling `prev` (Armada doesn't persist a floor, so a
2655/// Vector floor only makes Vector STRICTER locally — no wire change, honest-case
2656/// convergence preserved).
2657fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
2658    use crate::community::roles::Permissions;
2659    use std::collections::BTreeMap;
2660
2661    let owner_hex = community.owner().ok().map(|o| o.to_hex());
2662
2663    let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
2664    for e in editions {
2665        groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
2666    }
2667
2668    let mut out = community.clone();
2669    let mut changed = false;
2670    let mut heads = Vec::new();
2671    let mut gapped = false;
2672    for ((vsk_code, eid), group) in &groups {
2673        // This fold applies exactly two entities: community metadata (eid ==
2674        // community_id) and channel metadata. A vsk-2 whose eid equals the community
2675        // id is excluded — the floor row keys on the entity alone, so it would share
2676        // (and corrupt) the metadata chain's floor.
2677        let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
2678        let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
2679        if !is_meta && !is_channel {
2680            continue;
2681        }
2682        // Authority gate (CORD-04 §5): only editions whose author CURRENTLY holds the
2683        // entity's management bit are eligible. Pre-filtering before the fold means a
2684        // demoted admin's (possibly higher-version) edition can't be the head; the
2685        // highest AUTHORIZED head wins. The owner is supreme.
2686        let required = if is_meta { Permissions::MANAGE_METADATA } else { Permissions::MANAGE_CHANNELS };
2687        let authed: Vec<&ParsedEdition> = group
2688            .iter()
2689            .copied()
2690            .filter(|e| {
2691                let author = e.author.to_hex();
2692                // A banned npub's edits are dropped (CORD-04 §4), even if they still
2693                // held a bit via a not-yet-stripped grant.
2694                !authority.banned.contains(&author) && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
2695            })
2696            .collect();
2697        if authed.is_empty() {
2698            continue;
2699        }
2700        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
2701        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
2702        let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
2703        gapped |= entity_gapped;
2704        let Some(hi) = hi else { continue };
2705
2706        let head = authed[hi];
2707        heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
2708        if is_meta {
2709            if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
2710                changed |= apply_community_metadata(&mut out, meta);
2711            }
2712        } else if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
2713            // vsk-2 carries no community binding (shared v1 grammar); a same-owner
2714            // cross-community replay can inject a phantom PUBLIC channel (bounded:
2715            // root-scoped key, eids don't collide). Binding is a deferred wire change.
2716            changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
2717        }
2718    }
2719    ControlFold { updated: changed.then_some(out), heads, gapped }
2720}
2721
2722/// Fold one entity's editions against its persisted floor into a head index (into the
2723/// input slice) plus whether a TRACKING gap was hit (the caller pages older history).
2724/// Encapsulates the W2 refuse-downgrade policy: bootstrap at floor 0 (highest signed
2725/// head, what Armada shows across a compaction's dangling prev); adopt the chain-
2726/// anchored head, paging on an upper gap; converge a same-version fork at the floor to
2727/// the lower-inner-id winner; and fail closed otherwise.
2728fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
2729    let floor_v = floor.map(|f| f.0).unwrap_or(0);
2730    if floor_v == 0 {
2731        return (version::bootstrap_head(fold_eds, 0), false);
2732    }
2733    let floor_hash = floor.map(|f| &f.1);
2734    let held_inner = floor.and_then(|f| f.2);
2735    let result = version::fold(fold_eds, floor_v, floor_hash);
2736    if result.anchored {
2737        return (result.head, result.gap); // verified prefix; page any upper gap.
2738    }
2739    if result.head.is_none() && !result.gap {
2740        return (None, false); // everything below floor — a stale relay, no paging.
2741    }
2742    // Unanchored under a held floor: converge a same-version fork at the floor to its
2743    // deterministic winner (lower inner id; a NULL held id is always replaceable),
2744    // else fail closed.
2745    let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
2746    let win_hash = match fork {
2747        Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
2748        _ => return (None, true), // detached from our committed head → withholding.
2749    };
2750    let re = version::fold(fold_eds, floor_v, Some(&win_hash));
2751    if !re.anchored {
2752        return (None, true);
2753    }
2754    (re.head, re.gap)
2755}
2756
2757/// The folded, delegation-AUTHORIZED control-plane authority (CORD-04): the roster
2758/// (roles + grants, owner-seeded fixpoint), the enforced banlist, and the
2759/// role/grant/banlist heads to persist as refuse-downgrade floors. The owner is
2760/// recomputed from the self-certifying community_id at each use.
2761struct AuthoritySet {
2762    roles: crate::community::roles::CommunityRoles,
2763    banned: std::collections::BTreeSet<String>,
2764    heads: Vec<FoldedHead>,
2765    gapped: bool,
2766    /// The authorized banlist `(content, version)` to persist when an authorized head
2767    /// advanced the floor. `None` when the banlist was retained (no new authorized
2768    /// head) or is empty — the caller then leaves the stored banlist untouched.
2769    banlist_persist: Option<(Vec<String>, u64)>,
2770}
2771
2772impl AuthoritySet {
2773    /// Bootstrap authority for a community with no roster editions folded yet: only
2774    /// the owner is authorized (supreme), nobody banned.
2775    fn owner_only() -> Self {
2776        AuthoritySet { roles: Default::default(), banned: Default::default(), heads: vec![], gapped: false, banlist_persist: None }
2777    }
2778}
2779
2780/// Fold the roster/banlist entities (vsk 1/3/4) from the control editions into the
2781/// delegation-AUTHORIZED roster + enforced banlist (CORD-04 §2-§5). Each entity binds
2782/// to its coordinate (role at role_id, grant at grant_locator(cid, member), banlist at
2783/// banlist_locator(cid)); a content whose coordinate doesn't match is dropped. Roles
2784/// cap at the 100 lowest role_ids, a member at 64 roles, the banlist at 500. The
2785/// banlist is enforced only if its head's signer held BAN in the authorized roster.
2786fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
2787    use crate::community::roles::Permissions;
2788    use std::collections::BTreeMap;
2789
2790    let cid = community.id();
2791    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2792    let owner = community.owner().ok();
2793    let owner_hex = owner.map(|o| o.to_hex());
2794    let banlist_eid = super::derive::banlist_locator(cid);
2795    let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
2796
2797    let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
2798    for e in editions {
2799        if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
2800            groups.entry(e.entity_id).or_default().push(e);
2801        }
2802    }
2803
2804    // Per-entity CANDIDATE lists — every ≥floor edition of a role/grant, highest
2805    // version first (lowest inner-id as the deterministic tiebreak). CORD-04 §1: an
2806    // edition whose signer isn't authorized is SIMPLY DROPPED and the fold continues
2807    // to the next candidate, so a forged higher-version edition can't suppress the
2808    // authorized head beneath it (the author-blind collapse-to-one-head it replaces
2809    // let any member vanish a role or a member's grant). `gapped` (drives older-
2810    // paging) stays fold_head's per-entity flag.
2811    let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
2812    let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
2813    let mut gapped = false;
2814
2815    for (eid, group) in &groups {
2816        // The banlist is folded author-aware AFTER the roster is known (below).
2817        if *eid == banlist_eid {
2818            continue;
2819        }
2820        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
2821        let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
2822        let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
2823        gapped |= entity_gapped;
2824        let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
2825
2826        for p in group {
2827            // Refuse-downgrade: never consider an edition below the persisted floor.
2828            if p.version < floor_v {
2829                continue;
2830            }
2831            let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
2832            match p.vsk.as_str() {
2833                vsk::ROLE => {
2834                    // Bind: the content's role_id IS the coordinate; position 0 is the owner's.
2835                    if let Some(role) = super::roles::parse_role_content(&p.content) {
2836                        if role.role_id == entity_hex && role.position != 0 {
2837                            role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head });
2838                        }
2839                    }
2840                }
2841                vsk::GRANT => {
2842                    if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
2843                        if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
2844                            if super::derive::grant_locator(cid, &member) == *eid {
2845                                grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
2846                                grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head });
2847                            }
2848                        }
2849                    }
2850                }
2851                _ => {}
2852            }
2853        }
2854    }
2855    for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
2856        cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
2857    }
2858
2859    let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2860    // Preliminary roster (bans not yet applied) — the authority view the banlist head
2861    // is judged against.
2862    let (prelim, _) = select_authorized(&role_cands, &grant_cands, owner_hex.as_deref(), &empty);
2863
2864    // Banlist (CORD-04 §4), folded AUTHORITY-aware so its two anti-roster hazards are
2865    // both closed:
2866    //   - head selection: the head is the highest version whose author CURRENTLY holds
2867    //     BAN — an unauthorized higher-version edition can't erase existing bans
2868    //     (fail-open), and the floor never advances to one;
2869    //   - per-target: each entry is kept only if the author STRICTLY OUTRANKS that
2870    //     target (`can_act_on_member` — an admin can't ban a peer/superior, and the
2871    //     owner is unbannable);
2872    //   - withholding: when no authorized head is served, the persisted banlist is
2873    //     RETAINED (an anti-roster must not un-ban on a relay withholding the ban).
2874    let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2875    // An ALREADY-banned npub can't author the banlist (a banned member vanishes, §4), or
2876    // a BAN-holder whose grant-strip hasn't yet folded could publish a list omitting their
2877    // OWN ban to un-ban themselves (removals aren't outrank-checked). Exclude them from
2878    // head eligibility, not just from the roster.
2879    let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
2880    let banlist_authored: Vec<&ParsedEdition> = groups
2881        .get(&banlist_eid)
2882        .map(|g| {
2883            g.iter()
2884                .copied()
2885                .filter(|e| {
2886                    let ah = e.author.to_hex();
2887                    !banned_authors.contains(ah.as_str()) && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
2888                })
2889                .collect()
2890        })
2891        .unwrap_or_default();
2892    let mut banlist_persist: Option<(Vec<String>, u64)> = None;
2893    let mut banlist_head: Option<FoldedHead> = None;
2894    let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
2895        persisted_banned.into_iter().collect()
2896    } else {
2897        let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
2898        let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
2899        gapped |= g;
2900        match hi {
2901            Some(hi) => {
2902                let head = banlist_authored[hi];
2903                let ah = head.author.to_hex();
2904                let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
2905                    .unwrap_or_default()
2906                    .into_iter()
2907                    .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
2908                    .take(super::roles::MAX_BANLIST)
2909                    .collect();
2910                banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
2911                banlist_persist = Some((list.clone(), head.version));
2912                list.into_iter().collect()
2913            }
2914            None => persisted_banned.into_iter().collect(),
2915        }
2916    };
2917
2918    // Final roster (CORD-04 §4: a banned npub vanishes — every edition it authored is
2919    // dropped, and a grant TO a banned member carries no rank). Re-run selection with
2920    // the banned set excluded so a banned admin loses authority.
2921    let (mut authorized, mut heads) = select_authorized(&role_cands, &grant_cands, owner_hex.as_deref(), &banned);
2922    if let Some(bh) = banlist_head {
2923        heads.push(bh);
2924    }
2925
2926    // Cap the AUTHORIZED community at the 100 lowest role_ids — applied AFTER
2927    // authorization, so an attacker's unauthorized roles can't consume cap slots and
2928    // evict a legitimate one (the pre-authorize cap they replace let 100 forged low-id
2929    // roles empty the roster).
2930    if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
2931        authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
2932        authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
2933        let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
2934        authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
2935        authorized.grants.retain(|g| !g.role_ids.is_empty());
2936    }
2937
2938    AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist }
2939}
2940
2941/// One candidate edition of a role/grant entity — the pool [`select_authorized`]
2942/// draws the highest AUTHORIZED head from (exactly one of `role`/`grant` is set).
2943struct AuthorityCand {
2944    role: Option<crate::community::roles::Role>,
2945    grant: Option<crate::community::roles::MemberGrant>,
2946    author: PublicKey,
2947    head: FoldedHead,
2948}
2949
2950/// The owner-seeded delegation fixpoint (CORD-04 §1/§2), author-AWARE: per entity it
2951/// takes the highest-version candidate whose author is authorized to author it under
2952/// the roster resolved SO FAR, dropping unauthorized higher versions rather than
2953/// vanishing the entity. Authority resolves outward from the owner (proven by
2954/// `community_id`, never a Role), and the strict-outrank rule (no edition at/above its
2955/// signer's own position) keeps the fixpoint monotone, so it converges. Returns the
2956/// authorized roster plus the per-entity heads of the SELECTED editions (the floor
2957/// advances only to authorized heads — an unauthorized forgery never poisons it).
2958fn select_authorized(
2959    role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
2960    grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
2961    owner_hex: Option<&str>,
2962    excluded: &std::collections::BTreeSet<String>,
2963) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
2964    use crate::community::roles::{CommunityRoles, Permissions};
2965    let mut accepted = CommunityRoles::default();
2966    let mut heads: Vec<FoldedHead> = Vec::new();
2967    // Jacobi iteration: authority propagates one delegation level per round, so a
2968    // generous multiple of the entity count is an ample bound. Non-convergence (never
2969    // seen for an owner-rooted chain) falls through fail-safe: only authorized editions
2970    // are ever selected.
2971    let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
2972    for _ in 0..bound {
2973        let mut next = CommunityRoles::default();
2974        let mut next_heads: Vec<FoldedHead> = Vec::new();
2975
2976        for cands in role_cands.values() {
2977            for c in cands {
2978                let Some(role) = &c.role else { continue };
2979                let ah = c.author.to_hex();
2980                if excluded.contains(&ah) {
2981                    continue;
2982                }
2983                if role.position != 0 && accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
2984                    next.roles.push(role.clone());
2985                    next_heads.push(c.head.clone());
2986                    break; // highest authorized candidate for this entity
2987                }
2988            }
2989        }
2990        for cands in grant_cands.values() {
2991            for c in cands {
2992                let Some(grant) = &c.grant else { continue };
2993                let ah = c.author.to_hex();
2994                if excluded.contains(&ah) || excluded.contains(&grant.member) {
2995                    continue;
2996                }
2997                // The granter must outrank every granted role (resolved against the
2998                // accepted roster) AND the member — the escalation defense (CORD-04 §2).
2999                let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
3000                let Some(positions) = positions else { continue };
3001                if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
3002                    && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
3003                {
3004                    // Record the head even for an EMPTY grant (a revoke is a real chain
3005                    // advance a completeness check must see), but don't carry the husk
3006                    // into the roster.
3007                    next_heads.push(c.head.clone());
3008                    if !grant.role_ids.is_empty() {
3009                        next.grants.push(grant.clone());
3010                    }
3011                    break;
3012                }
3013            }
3014        }
3015
3016        let converged = next.roles == accepted.roles && next.grants == accepted.grants;
3017        accepted = next;
3018        heads = next_heads;
3019        if converged {
3020            break;
3021        }
3022    }
3023    (accepted, heads)
3024}
3025
3026/// Apply a folded community-metadata head. Relays only overwrite when the edition
3027/// carries a non-empty list (a metadata edition that omits relays must not blank
3028/// the working set). Returns whether anything changed.
3029fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
3030    let mut changed = false;
3031    if out.name != meta.name {
3032        out.name = meta.name;
3033        changed = true;
3034    }
3035    if out.description != meta.description {
3036        out.description = meta.description;
3037        changed = true;
3038    }
3039    // Icon/banner apply verbatim, None included — an edition is the full
3040    // document, so an absent image IS a removal (editors preserve via
3041    // `CommunityV2::metadata()`).
3042    if out.icon != meta.icon {
3043        out.icon = meta.icon;
3044        changed = true;
3045    }
3046    if out.banner != meta.banner {
3047        out.banner = meta.banner;
3048        changed = true;
3049    }
3050    // Client-extensible + unknown fields ride the fold verbatim so our own
3051    // editions can carry them forward (CORD-02 §6).
3052    if out.meta_custom != meta.custom {
3053        out.meta_custom = meta.custom;
3054        changed = true;
3055    }
3056    if out.meta_extra != meta.extra {
3057        out.meta_extra = meta.extra;
3058        changed = true;
3059    }
3060    if !meta.relays.is_empty() && out.relays != meta.relays {
3061        out.relays = meta.relays;
3062        changed = true;
3063    }
3064    changed
3065}
3066
3067/// Apply a folded channel-metadata head: delete removes the channel, a rename
3068/// updates an existing one, a brand-new PUBLIC channel is added, and a brand-new
3069/// PRIVATE one is recorded KEYLESS (unreadable until its key arrives over the
3070/// rekey plane or a fresh bundle). Returns whether anything changed.
3071fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
3072    let deleted = meta.deleted.unwrap_or(false);
3073    if deleted {
3074        let before = out.channels.len();
3075        out.channels.retain(|c| c.id.0 != id.0);
3076        return out.channels.len() != before;
3077    }
3078    match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
3079        Some(existing) => {
3080            let mut changed = false;
3081            if existing.name != meta.name {
3082                existing.name = meta.name;
3083                changed = true;
3084            }
3085            // vsk-2 fields Vector doesn't drive still fold + persist, so a later
3086            // local edit republishes them instead of wiping (CORD-02 §6).
3087            if existing.voice != meta.voice {
3088                existing.voice = meta.voice;
3089                changed = true;
3090            }
3091            if existing.meta_custom != meta.custom {
3092                existing.meta_custom = meta.custom;
3093                changed = true;
3094            }
3095            if existing.meta_extra != meta.extra {
3096                existing.meta_extra = meta.extra;
3097                changed = true;
3098            }
3099            // The owner's edition authoritatively declares visibility. A channel the
3100            // owner marks PUBLIC must derive from the root (key = None) — this heals a
3101            // bundle-time misclassification where an attacker set a public channel's
3102            // grant key to their own, silently addressing it at a plane only they read.
3103            // Public → private CONVERSION is DEFERRED: the flip is IGNORED here (the
3104            // record stays public) until the convert flow (key mint + cursor rebase
3105            // to the conversion's channel epoch) lands — the send side refuses to
3106            // publish one, and a foreign client's conversion won't move us.
3107            if !meta.private && (existing.private || existing.key.is_some()) {
3108                existing.private = false;
3109                existing.key = None;
3110                changed = true;
3111            }
3112            changed
3113        }
3114        None if !meta.private => {
3115            // A public channel derives its Chat Plane from the community_root at the
3116            // current root epoch (key = None); its stored epoch mirrors the root.
3117            out.channels.push(ChannelV2 {
3118                id,
3119                name: meta.name,
3120                private: false,
3121                key: None,
3122                epoch: out.root_epoch,
3123                voice: meta.voice,
3124                meta_custom: meta.custom,
3125                meta_extra: meta.extra,
3126            });
3127            true
3128        }
3129        None => {
3130            // A brand-new PRIVATE channel: record it KEYLESS at epoch 0 (the root
3131            // generation — CORD-03 §2 numbers the first private key epoch 1). The
3132            // epoch then doubles as [`follow_rekeys`]' scan cursor. Until a rotation
3133            // delivers a key, every read/send/subscribe path skips the channel; the
3134            // root-fallback in `channel_secret` is never taken for it.
3135            out.channels.push(ChannelV2 {
3136                id,
3137                name: meta.name,
3138                private: true,
3139                key: None,
3140                epoch: Epoch(0),
3141                voice: meta.voice,
3142                meta_custom: meta.custom,
3143                meta_extra: meta.extra,
3144            });
3145            true
3146        }
3147    }
3148}
3149
3150// ── Live rekey-follow (CORD-06 §2/§3) ────────────────────────────────────────
3151
3152/// The outcome of a rekey-follow pass.
3153pub struct RekeyFollow {
3154    /// The community after adopting every rotation it could catch up on, or `None`
3155    /// if nothing advanced.
3156    pub updated: Option<CommunityV2>,
3157    /// A base rotation removed us — the caller tears the local hold down (the
3158    /// updated community is not persisted in that case).
3159    pub self_removed: bool,
3160    /// An owner tombstone sits on the dissolved plane (CORD-02 §9) — the local
3161    /// flag is already set; the caller surfaces the death and stops following.
3162    pub dissolved: bool,
3163}
3164
3165/// The most archived base roots a channel-rekey lookup fans across per step. A
3166/// standalone rekey rides the minter's then-current root and a removal's rides the
3167/// PRIOR root (CORD-06 §3), so a follower whose base already advanced must look
3168/// back. A channel stranded DEEPER than this (its next-epoch crate addressed under
3169/// an older root than the fan reaches) only heals via a fresh invite bundle — the
3170/// walk is strictly sequential, so a later rotation can't be reached either.
3171const MAX_ADDRESSING_ROOTS: usize = 8;
3172
3173/// The base roots a channel rekey may be addressed under, freshest first: the
3174/// current root plus the archived priors, capped at [`MAX_ADDRESSING_ROOTS`].
3175/// CORD-06 D2: a removal-forced channel rekey rides the PRIOR root — so the
3176/// follower's fetch fan ([`follow_rekeys`]) and the stream-auth registration
3177/// (`streamauth::register_community`) MUST cover the SAME set. A plane the
3178/// fetch addresses but auth never registered is invisible on an AUTH-gating
3179/// relay: the REQ is CLOSED, the rotation crate never arrives, and the channel
3180/// wedges at its old epoch while the base advances.
3181pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
3182    let mut roots: Vec<[u8; 32]> = vec![cur_root];
3183    let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
3184        .unwrap_or_default();
3185    archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
3186    for (_, r) in archived {
3187        if !roots.contains(&r) {
3188            roots.push(r);
3189        }
3190    }
3191    roots.truncate(MAX_ADDRESSING_ROOTS);
3192    roots
3193}
3194
3195/// Follow rekeys for a held community: advance the base (root) epoch and each
3196/// Private channel's epoch as far as authorized rotations allow, adopting the
3197/// fresh key we're still a recipient of at each step and dropping a scope we've
3198/// been removed from. Persists the result. Called when a rekey wrap arrives in
3199/// realtime so a long-running bot keeps decrypting after a rotation instead of
3200/// going silent.
3201///
3202/// **Authority (CORD-06 §Authority):** a BASE rotation is honored from the owner
3203/// only — the deliberate mirror of the owner-only Refounding send (a non-owner's
3204/// ban silences + strips; the read-cut is the owner's). A CHANNEL rotation is
3205/// honored from the owner or a `MANAGE_CHANNELS` holder under the PERSISTED
3206/// roster (folded + persisted by `follow_control`), minus the banlist — so an
3207/// admin-created private channel keys up on every member.
3208///
3209/// **Addressing fans across held base roots:** each channel step queries its
3210/// next-epoch rekey address under the current root AND the archived prior roots,
3211/// so a base adopt landing before a Refounding's prior-root-addressed channel
3212/// rekeys (or before a creation delivery minted under an older root) can't
3213/// strand the channel.
3214///
3215/// **Continuity + fork resolution are spec-strict:** a rotation must extend the
3216/// exact `(epoch, key)` I hold, one epoch at a time; a same-epoch fork resolves
3217/// by the lexicographically lowest new key ([`rekey::lowest_key_winner`]), so
3218/// every follower converges. An incomplete rotation (a missing chunk) never
3219/// concludes removal — it just waits. A KEYLESS channel (announced by vsk-2, key
3220/// not yet delivered) holds no chain, so continuity is vacuous for it (CORD-06
3221/// §2: "a convergence check, not a secrecy mechanism") — authority is its
3222/// boundary; its epoch is the scan cursor, advancing past complete rotations
3223/// that exclude us so the walk converges on the channel's current epoch.
3224/// Diagnostic: run the base-rotation fetch+parse pipeline for a wedged community
3225/// and report, per rotation found at the next-epoch base plane, WHY
3226/// `follow_rekeys` did or didn't adopt it — the exact `advance_scope` gate that
3227/// tripped. Read-only. Every rotator/owner is a PUBLIC key; no secret material
3228/// is returned.
3229#[cfg(debug_assertions)]
3230pub async fn debug_explain_base_rekey<T: Transport + ?Sized>(
3231    transport: &T,
3232    community: &CommunityV2,
3233) -> Result<serde_json::Value, String> {
3234    let me = local_keys()?;
3235    let my_xonly = me.public_key().to_bytes();
3236    let owner = community.owner()?;
3237    let owner_hex = owner.to_hex();
3238    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3239    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3240    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3241    let held_epoch = community.root_epoch;
3242    let held_key = community.community_root;
3243    let next = Epoch(held_epoch.0.saturating_add(1));
3244    let group = base_rekey_group_key(&held_key, community.id(), next);
3245    let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
3246    let rotations = rekey::collect_rotations(&chunks);
3247
3248    let reports: Vec<serde_json::Value> = rotations
3249        .iter()
3250        .map(|r| {
3251            let rotator_is_owner = r.rotator == owner;
3252            // CORD-06 §Authority: a Refounding is authorized by BAN in the folded
3253            // Roster, not owner-identity — report that gate, not just owner-equality.
3254            let rotator_authorized = rotator_is_owner
3255                || (!banned.contains(&r.rotator.to_hex())
3256                    && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
3257            let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
3258            let epoch_ok = r.new_epoch.0 == next.0;
3259            let complete = r.is_complete();
3260            let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
3261            let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
3262            // Is the OWNER a recipient? A non-owner Refounding that drops the owner
3263            // is a takeover attempt — this tells whether an "owner must be kept"
3264            // adopt-block would be safe here (it would falsely reject a legitimate
3265            // rotation that happened to exclude the owner).
3266            let owner_kept = r.rotator == owner
3267                || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
3268            // The exact reason follow_rekeys skipped/rejected this rotation, in gate order.
3269            let verdict = if !rotator_authorized {
3270                "REJECTED: rotator holds no BAN authority in the folded roster"
3271            } else if !scope_ok {
3272                "REJECTED: scope is not Root"
3273            } else if !epoch_ok {
3274                "REJECTED: new_epoch != held+1"
3275            } else if !complete {
3276                "WAIT: rotation incomplete (missing chunk) — never concludes removal"
3277            } else if continuity != "Extends" {
3278                "REJECTED: continuity does not extend my held root (FORK/GAP)"
3279            } else if has_my_blob {
3280                "ADOPT: authorized + complete + continuous + my blob present"
3281            } else {
3282                "REMOVED: complete authorized rotation with no blob for me"
3283            };
3284            serde_json::json!({
3285                "rotator": r.rotator.to_hex(),
3286                "rotator_is_recorded_owner": rotator_is_owner,
3287                "rotator_authorized_ban": rotator_authorized,
3288                "scope_is_root": scope_ok,
3289                "new_epoch": r.new_epoch.0,
3290                "prev_epoch": r.prev_epoch.0,
3291                "declared_chunks": r.declared_chunks,
3292                "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
3293                "is_complete": complete,
3294                "continuity_vs_held_root": continuity,
3295                "my_blob_present": has_my_blob,
3296                "owner_kept": owner_kept,
3297                "blob_count": r.blobs.len(),
3298                "verdict": verdict,
3299            })
3300        })
3301        .collect();
3302
3303    Ok(serde_json::json!({
3304        "recorded_owner": owner.to_hex(),
3305        "held_root_epoch": held_epoch.0,
3306        "probing_next_epoch": next.0,
3307        "base_plane_pk": group.pk_hex(),
3308        "raw_chunks_parsed": chunks.len(),
3309        "rotations_found": rotations.len(),
3310        "rotations": reports,
3311    }))
3312}
3313
3314pub async fn follow_rekeys<T: Transport + ?Sized>(
3315    transport: &T,
3316    community: &CommunityV2,
3317    session: &SessionGuard,
3318) -> Result<RekeyFollow, String> {
3319    // Death wins every race (CORD-02 §9): a dissolved community honors no epoch advance
3320    // past its tombstone — don't adopt a rotation into a grave.
3321    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3322    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
3323        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
3324    }
3325    // An offline member must also LEARN of a death: the tombstone rides its own
3326    // public plane, which the live sub watches but no catch-up fetch touched —
3327    // without this, a member who slept through a dissolution follows (and posts
3328    // into) a grave forever. Fail-open on transport failure: availability is
3329    // never death.
3330    if is_dissolved(transport, community).await {
3331        if session.is_valid() {
3332            let _ = crate::db::community::set_community_dissolved(&cid_hex);
3333        }
3334        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
3335    }
3336    let me = local_keys()?;
3337    let my_xonly = me.public_key().to_bytes();
3338    let owner = community.owner()?;
3339    let owner_hex = owner.to_hex();
3340    let mut cur = community.clone();
3341    let mut changed = false;
3342
3343    // The rotator/admissibility gates read the PERSISTED roster (folded by a prior
3344    // follow_control; the worker folds control right after this rekey pass). This
3345    // is "one pass late" for the rotator-AUTHORIZATION direction (a newly-granted
3346    // admin's rotation adopts a pass late, never early — safe). It is fail-OPEN for
3347    // the base-admissibility protected-set: a superior whose grant this receiver
3348    // has not yet folded is not in `roster.grants`, so a non-owner Refounding
3349    // excluding them can be adopted within that propagation window. Bounded — the
3350    // owner is ALWAYS hard-protected below (independent of the roster) and can
3351    // counter-refound; and it is inherent to eventual consistency (one cannot gate
3352    // on a grant never seen). Tightening this (fold control before the first rekey,
3353    // or gate non-owner adoption on roster freshness) is a follow-on.
3354    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3355    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3356    let me_hex = me.public_key().to_hex();
3357    let channel_rotator_ok = |rotator: &PublicKey| -> bool {
3358        if *rotator == owner {
3359            return true;
3360        }
3361        let rh = rotator.to_hex();
3362        !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
3363    };
3364    // Concluding MY removal takes more than the bit: the rotator must strictly
3365    // outrank ME (CORD-06 §Authority — "the Rotator must strictly outrank every
3366    // removed target"), so an equal-rank admin can never silently evict a peer
3367    // (or the owner) by minting a complete rotation that skips their blob.
3368    let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
3369        if *rotator == owner {
3370            return true;
3371        }
3372        let rh = rotator.to_hex();
3373        !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
3374    };
3375    // CORD-06 §Authority: a Refounding requires the BAN permission in the folded
3376    // Roster (NOT owner-identity) — any admin holding BAN may perform it, checked
3377    // against the Roster exactly like a channel rekey checks MANAGE_CHANNELS. The
3378    // owner is always authorized. (Owner-only here silently wedged every member
3379    // whose community was refounded by a non-owner admin.)
3380    let base_rotator_ok = |rotator: &PublicKey| -> bool {
3381        if *rotator == owner {
3382            return true;
3383        }
3384        let rh = rotator.to_hex();
3385        !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
3386    };
3387    // Concluding MY removal via a base rotation takes more than the bit: the
3388    // rotator must strictly outrank ME with BAN (CORD-06 §Authority — "the
3389    // Rotator must strictly outrank every removed target"), so an equal-rank
3390    // admin can never evict a peer (or the owner) by minting a rotation that
3391    // skips their blob. Adoption (I hold a blob) only needs `base_rotator_ok`.
3392    let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
3393        if *rotator == owner {
3394            return true;
3395        }
3396        let rh = rotator.to_hex();
3397        !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
3398    };
3399
3400    // Bound the catch-up: each real step consumes a valid authorized rotation, so a
3401    // finite chain terminates naturally; the cap defends against a relay feeding a
3402    // pathological set.
3403    const MAX_STEPS: usize = 128;
3404    for _ in 0..MAX_STEPS {
3405        let mut advanced = false;
3406
3407        // The roots a channel rekey may be addressed under (re-read each pass —
3408        // a base adopt below changes the head, and its predecessor is already
3409        // archived). Shared with streamauth so the auth registration covers
3410        // exactly this fan.
3411        let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
3412
3413        // Private channels first: a removal-forced channel rekey rides the PRIOR
3414        // root (CORD-06 D2), so read channels before a base adopt moves it.
3415        let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
3416        for cid in channel_ids {
3417            let (held_key, held_epoch) = match cur.channel(&cid) {
3418                Some(ch) => (ch.key, ch.epoch),
3419                None => continue,
3420            };
3421            let next = Epoch(held_epoch.0.saturating_add(1));
3422            let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
3423            let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
3424            // root #0 = current, #1.. = archived priors (indices only — root
3425            // bytes are key material and must never reach a log).
3426            for (ri, root) in addressing_roots.iter().enumerate() {
3427                let group = channel_rekey_group_key(root, &cid, next);
3428                let chunks = match fetch_rekey_chunks(transport, &cur.relays, &group).await {
3429                    Ok(c) => c,
3430                    Err(e) => {
3431                        crate::log_warn!(
3432                            "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
3433                            &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
3434                        );
3435                        return Err(e);
3436                    }
3437                };
3438                if chunks.is_empty() {
3439                    continue;
3440                }
3441                crate::log_debug!(
3442                    "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
3443                    &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
3444                );
3445                batches.push((chunks, held_key.map(|k| (held_epoch, k))));
3446            }
3447            // Keyless-adopt residual (documented, deferred hardening): a malicious
3448            // AUTHORIZED admin can fork a keyless member onto an orphan low-key
3449            // rotation nothing extends (keyed members' continuity filters it out).
3450            // Recoverable via a fresh bundle; an insider with MANAGE_CHANNELS can
3451            // exclude the member outright anyway, so the marginal harm is the wedge
3452            // outliving their demotion.
3453            match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, &|_r: &rekey::Rotation| true, me.secret_key(), &my_xonly, next) {
3454                Advance::Adopt { new_key } => {
3455                    if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
3456                        ch.key = Some(new_key);
3457                        ch.epoch = next;
3458                    }
3459                    crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
3460                    // The adopter's own multi-epoch archive (the minter archived at
3461                    // mint) — this channel's history stays readable across rotations.
3462                    // fetch_channel compensates for the CURRENT epoch, so a failed
3463                    // archive only bites after the NEXT rotation — surface it.
3464                    if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&cid.0), next.0, &new_key) {
3465                        crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
3466                    }
3467                    advanced = true;
3468                    changed = true;
3469                }
3470                Advance::Removed => {
3471                    match held_key {
3472                        // A complete rotation dropped my blob — cut from the channel.
3473                        Some(_) => {
3474                            cur.channels.retain(|c| c.id.0 != cid.0);
3475                        }
3476                        // Keyless scan: this epoch's rotation completed without me.
3477                        // Advance the cursor so the walk converges on the channel's
3478                        // CURRENT epoch — my entry point is its next rotation (whose
3479                        // recipients are the members at that time) or a fresh bundle.
3480                        None => {
3481                            if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
3482                                ch.epoch = next;
3483                            }
3484                        }
3485                    }
3486                    advanced = true;
3487                    changed = true;
3488                }
3489                Advance::Stay => {}
3490            }
3491        }
3492
3493        // Base rotation (Refounding): advances the root + root_epoch, re-addressing
3494        // every public channel, the guestbook, and the control plane by derivation
3495        // (refresh_subscription recomputes the author-set from the new root).
3496        {
3497            let held_epoch = cur.root_epoch;
3498            let held_key = cur.community_root;
3499            let next = Epoch(held_epoch.0.saturating_add(1));
3500            let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
3501            let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
3502            let batches = vec![(chunks, Some((held_epoch, held_key)))];
3503            // A non-owner Refounding may only remove members the rotator strictly
3504            // OUTRANKS. The protected set is the owner plus every grant-holder the
3505            // rotator can't act on with BAN (a peer or superior) — excluding one is
3506            // an authority-escalation takeover, so its rotation is inadmissible.
3507            // Plain members hold no grant and are always outranked by a BAN-holder,
3508            // so removing them is legitimate and needs no memberlist.
3509            let base_admissible = |r: &rekey::Rotation| -> bool {
3510                if r.rotator == owner {
3511                    return true; // the owner is supreme.
3512                }
3513                let rotator_hex = r.rotator.to_hex();
3514                let has_blob = |xonly: &[u8; 32]| {
3515                    rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
3516                };
3517                // The owner is never a valid removed target.
3518                if !has_blob(&owner.to_bytes()) {
3519                    return false;
3520                }
3521                for g in &roster.grants {
3522                    if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
3523                        continue; // self, owner (checked), or an already-authorized removal.
3524                    }
3525                    // A grant-holder the rotator can't act on is a peer/superior.
3526                    if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
3527                        if let Ok(pk) = PublicKey::from_hex(&g.member) {
3528                            if !has_blob(&pk.to_bytes()) {
3529                                return false; // a peer/superior was excluded.
3530                            }
3531                        }
3532                    }
3533                }
3534                true
3535            };
3536            match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, me.secret_key(), &my_xonly, next) {
3537                Advance::Adopt { new_key } => {
3538                    cur.community_root = new_key;
3539                    cur.root_epoch = next;
3540                    // Archive on adopt: without this, a member who lived through TWO
3541                    // Refoundings loses the middle epoch's public history (only the
3542                    // minter archived it).
3543                    if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
3544                        crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
3545                    }
3546                    advanced = true;
3547                    changed = true;
3548                }
3549                Advance::Removed => {
3550                    if !session.is_valid() {
3551                        return Err("account changed during rekey follow".to_string());
3552                    }
3553                    return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
3554                }
3555                Advance::Stay => {}
3556            }
3557        }
3558
3559        if !advanced {
3560            break;
3561        }
3562    }
3563
3564    if !changed {
3565        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
3566    }
3567    if !session.is_valid() {
3568        return Err("account changed during rekey follow".to_string());
3569    }
3570    // A leave/delete raced this follow: saving would resurrect the community row
3571    // (the save is an upsert) with no floor rows behind it.
3572    if crate::db::community::community_protocol(community.id())?.is_none() {
3573        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
3574    }
3575    crate::db::community::save_community_v2(&cur)?;
3576    Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
3577}
3578
3579/// One scope's catch-up decision from the rekey chunks fetched at its next-epoch
3580/// address.
3581enum Advance {
3582    /// Adopt this fresh key for `next_epoch`.
3583    Adopt { new_key: [u8; 32] },
3584    /// A complete owner rotation at `next_epoch` dropped my blob — I'm removed.
3585    Removed,
3586    /// No owner rotation extends my held epoch (yet) — keep the current key.
3587    Stay,
3588}
3589
3590/// Fetch + parse every seal-verified 3303 chunk at a rekey plane address.
3591async fn fetch_rekey_chunks<T: Transport + ?Sized>(
3592    transport: &T,
3593    relays: &[String],
3594    group: &GroupKey,
3595) -> Result<Vec<rekey::RekeyChunk>, String> {
3596    // A rekey plane address is community_root-derived, so ANY member can seal junk
3597    // 3303s there — a flood (or, organically, a large community's own multi-chunk
3598    // rotation past the newest window) could bury the genuine owner/admin rotation
3599    // in a single fixed page. PAGE backwards (inclusive until + wrap-id dedup, the
3600    // control pager's discipline) so a buried authorized chunk is still recovered;
3601    // the seal + authority filter downstream drops the junk. Bounded — a sustained
3602    // flood past this depth degrades to "adopt one pass late", never a false state.
3603    const REKEY_PAGE: usize = 200;
3604    const REKEY_MAX_PAGES: usize = 6;
3605    let mut out = Vec::new();
3606    let mut seen: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
3607    let mut until: Option<u64> = None;
3608    let mut oldest: Option<u64> = None;
3609    for _ in 0..REKEY_MAX_PAGES {
3610        let query = Query {
3611            kinds: vec![stream::KIND_WRAP],
3612            authors: vec![group.pk_hex()],
3613            until,
3614            limit: Some(REKEY_PAGE),
3615            ..Default::default()
3616        };
3617        // Authenticate AS the rekey plane key: on AUTH-gating relays (Ditto) the
3618        // shared user-authed client's REQ for a plane's events is CLOSED, so an
3619        // offline rotation catch-up would return nothing and wedge at the old
3620        // epoch. `fetch_plane` rides a connection authed as the plane itself.
3621        let wraps = transport.fetch_plane(group.keys(), &query, relays).await?;
3622        let mut fresh = 0usize;
3623        for w in &wraps {
3624            if !seen.insert(w.id) {
3625                continue;
3626            }
3627            fresh += 1;
3628            let at = w.created_at.as_secs();
3629            if oldest.is_none_or(|o| at < o) {
3630                oldest = Some(at);
3631            }
3632            if let Ok(opened) = stream::open_wrap(w, group) {
3633                if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
3634                    out.push(chunk);
3635                }
3636            }
3637        }
3638        // Drained, or a same-second wall the pager can't step past (second-granular
3639        // until) — either way stop; the accumulated set is what advance_scope folds.
3640        if fresh == 0 || wraps.len() < REKEY_PAGE {
3641            break;
3642        }
3643        match oldest {
3644            Some(o) if o > 0 => until = Some(o),
3645            _ => break,
3646        }
3647    }
3648    Ok(out)
3649}
3650
3651/// Decide how a scope advances from per-addressing-root chunk batches (pure). Each
3652/// batch pairs the chunks fetched under one root with the continuity to demand of
3653/// them: a rotation qualifies when it's rotator-authorized (`rotator_ok`),
3654/// complete, targets the immediate `next_epoch`, and — when I hold a chain —
3655/// extends my exact `(epoch, key)`. A KEYLESS batch (`held` = None) has no chain
3656/// to extend, so it qualifies on authority + completeness alone (CORD-06 §2:
3657/// continuity is "a convergence check, not a secrecy mechanism"; the rotator's
3658/// seal authority is the boundary). Among qualifying rotations carrying my blob
3659/// the lexicographically lowest new key wins (convergent). All complete
3660/// candidates without my blob conclude Removed for a KEYED holder only when one
3661/// came from a rotator who may remove ME (`rotator_may_remove_me`, the CORD-06
3662/// strict-outrank rule) — else Stay; for a keyless holder they merely advance the
3663/// scan cursor (any bit-holder's real rotation is scan progress, never a loss).
3664fn advance_scope(
3665    batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
3666    scope: RekeyScope,
3667    rotator_ok: &dyn Fn(&PublicKey) -> bool,
3668    rotator_may_remove_me: &dyn Fn(&PublicKey) -> bool,
3669    admissible: &dyn Fn(&rekey::Rotation) -> bool,
3670    my_sk: &SecretKey,
3671    my_xonly: &[u8; 32],
3672    next_epoch: Epoch,
3673) -> Advance {
3674    let mut winners: Vec<[u8; 32]> = Vec::new();
3675    let mut saw_complete_candidate = false;
3676    let mut saw_outranking_candidate = false;
3677    let keyed = batches.iter().any(|(_, held)| held.is_some());
3678    for (chunks, held) in batches {
3679        let rotations = rekey::collect_rotations(chunks);
3680        for r in &rotations {
3681            if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
3682                continue;
3683            }
3684            if let Some((held_epoch, held_key)) = held {
3685                if r.continuity(*held_epoch, held_key) != Continuity::Extends {
3686                    continue;
3687                }
3688            }
3689            // CORD-06 §Authority: a rotator must strictly OUTRANK every removed
3690            // target. An authorized-but-inadmissible rotation (one that excludes
3691            // the owner or a peer/superior the rotator can't act on) is a takeover
3692            // attempt — skip it entirely, so it neither adopts nor concludes a
3693            // removal (it forks; the honest chain wins).
3694            if !admissible(r) {
3695                continue;
3696            }
3697            saw_complete_candidate = true;
3698            saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
3699            if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
3700                if let Ok(k) = rekey::open_blob_local(my_sk, &r.rotator, r.scope, r.new_epoch, blob) {
3701                    winners.push(k);
3702                }
3703            }
3704        }
3705    }
3706    if !winners.is_empty() {
3707        // `collect_rotations` correlates on `(rotator, scope, new_epoch, prev_commit)`,
3708        // so a single rotator's blobs merge into ONE rotation (and a retried Refounding
3709        // MINT-OR-REUSES its root, so it never emits two distinct roots to fork on).
3710        // The lowest-key tiebreak engages only for CONCURRENT DISTINCT rotators racing
3711        // the same epoch (separate rotations): every follower converges on the same
3712        // lowest new key. A wrap served under two addressing roots can't double-count:
3713        // each rekey wrap opens under exactly one root's group key.
3714        let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
3715        return Advance::Adopt { new_key: winners[idx] };
3716    }
3717    if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
3718        Advance::Removed
3719    } else {
3720        Advance::Stay
3721    }
3722}
3723
3724#[cfg(test)]
3725mod tests {
3726    use super::super::super::transport::memory::MemoryRelay;
3727    use super::*;
3728    use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
3729
3730    /// A distinct npub-shaped account-dir name (bech32 charset) per counter.
3731    fn account_name(n: u32) -> String {
3732        const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3733        let mut acct = String::from("npub1");
3734        let mut v = n as usize;
3735        for _ in 0..58 {
3736            acct.push(B[v % 32] as char);
3737            v = v / 32 + 7;
3738        }
3739        acct
3740    }
3741
3742    /// One test participant: its identity keys and its isolated account DB dir.
3743    struct Actor {
3744        keys: Keys,
3745        account: String,
3746    }
3747
3748    /// Two participants sharing one relay but isolated per-account DBs — the
3749    /// cross-account harness a real invite/join loop needs. `swap_to` mirrors a
3750    /// live `swap_session`: re-point the DB pool + rebind the identity + clear
3751    /// the per-account id caches, so account A's community is invisible to B
3752    /// until B legitimately joins.
3753    struct TestBed {
3754        _tmp: tempfile::TempDir,
3755        _guard: std::sync::MutexGuard<'static, ()>,
3756        relay: MemoryRelay,
3757        relays: Vec<String>,
3758    }
3759
3760    impl TestBed {
3761        fn new() -> (TestBed, Actor, Actor) {
3762            static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
3763            let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3764            crate::db::close_database();
3765            crate::db::clear_id_caches();
3766            let tmp = tempfile::tempdir().unwrap();
3767            crate::db::set_app_data_dir(tmp.path().to_path_buf());
3768
3769            let mut mk = || {
3770                let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3771                let account = account_name(n);
3772                std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3773                crate::db::set_current_account(account.clone()).unwrap();
3774                crate::db::init_database(&account).unwrap();
3775                Actor { keys: Keys::generate(), account }
3776            };
3777            let owner = mk();
3778            let member = mk();
3779            let _ = crate::state::take_nostr_client();
3780            let bed = TestBed {
3781                _tmp: tmp,
3782                _guard: guard,
3783                relay: MemoryRelay::new(),
3784                relays: vec!["wss://r".to_string()],
3785            };
3786            (bed, owner, member)
3787        }
3788
3789        /// Become `actor`: swap the account DB + identity, as a real session swap.
3790        fn swap_to(&self, actor: &Actor) {
3791            crate::db::set_current_account(actor.account.clone()).unwrap();
3792            crate::db::init_database(&actor.account).unwrap();
3793            crate::db::clear_id_caches();
3794            crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
3795            crate::state::set_my_public_key(actor.keys.public_key());
3796        }
3797    }
3798
3799    /// Legacy single-actor helper (the create/send tests below).
3800    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
3801        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3802        crate::db::close_database();
3803        crate::db::clear_id_caches();
3804        let tmp = tempfile::tempdir().unwrap();
3805        static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
3806        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3807        let acct = account_name(n);
3808        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
3809        crate::db::set_app_data_dir(tmp.path().to_path_buf());
3810        crate::db::set_current_account(acct.clone()).unwrap();
3811        crate::db::init_database(&acct).unwrap();
3812        let _ = crate::state::take_nostr_client();
3813        let owner = Keys::generate();
3814        crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
3815        crate::state::set_my_public_key(owner.public_key());
3816        (tmp, guard, owner)
3817    }
3818
3819    /// A transport that simulates a session swap landing DURING a fetch await —
3820    /// so a join straddling the fetch sees an invalid session and aborts.
3821    struct SwapMidFetch {
3822        inner: MemoryRelay,
3823    }
3824    #[async_trait::async_trait]
3825    impl Transport for SwapMidFetch {
3826        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
3827        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
3828            self.inner.publish(e, r).await
3829        }
3830        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
3831            self.inner.publish_durable(e, r).await
3832        }
3833        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
3834            let out = self.inner.fetch(q, r).await;
3835            crate::state::bump_session_generation();
3836            out
3837        }
3838    }
3839
3840    /// A transport whose `fetch` returns a FIXED, UNSORTED event list — modelling
3841    /// the production `LiveTransport` union (first-responding relay's batch, no
3842    /// global newest-first sort), which `MemoryRelay` hides by sorting. This is
3843    /// the only harness that can exercise the revocation-race ordering.
3844    struct FixedFetch {
3845        events: Vec<Event>,
3846    }
3847    #[async_trait::async_trait]
3848    impl Transport for FixedFetch {
3849        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
3850        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
3851            Ok(())
3852        }
3853        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
3854            Ok(())
3855        }
3856        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
3857            Ok(self.events.clone())
3858        }
3859    }
3860
3861    /// Fetch a pending Direct Invite (kind 3313 giftwrap) addressed to `me` — the
3862    /// indexed inbox query CORD-05 §6 defines: `{1059, #p:[me], #k:["3313"]}`.
3863    async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
3864        let q = Query {
3865            kinds: vec![stream::KIND_WRAP],
3866            p_tags: vec![me.to_hex()],
3867            k_tags: vec!["3313".to_string()],
3868            ..Default::default()
3869        };
3870        relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
3871    }
3872
3873    #[tokio::test]
3874    async fn create_persists_and_reloads_a_v2_community() {
3875        let (_tmp, _guard, owner) = init_test_db();
3876        let relay = MemoryRelay::new();
3877        let relays = vec!["wss://r".to_string()];
3878
3879        let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
3880        assert!(created.identity.verify());
3881        assert_eq!(created.owner().unwrap(), owner.public_key());
3882        assert_eq!(created.channels.len(), 1);
3883
3884        // Protocol dispatch sees it as v2, and it reloads byte-faithfully.
3885        assert_eq!(
3886            crate::db::community::community_protocol(created.id()).unwrap(),
3887            Some(crate::community::ConcordProtocol::V2)
3888        );
3889        let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
3890        assert_eq!(loaded.name, "Vectorville");
3891        assert_eq!(loaded.community_root, created.community_root);
3892        assert_eq!(loaded.identity, created.identity);
3893        assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
3894        assert!(!loaded.channels[0].private);
3895
3896        // The genesis control editions + the owner Join landed on the relay.
3897        assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
3898    }
3899
3900    #[tokio::test]
3901    async fn owner_sends_and_reads_back_a_message() {
3902        let (_tmp, _guard, _owner) = init_test_db();
3903        let relay = MemoryRelay::new();
3904        let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
3905        let general = community.channels[0].id;
3906
3907        let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
3908        let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
3909        assert_ne!(id1, id2);
3910
3911        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
3912        let texts: Vec<String> = page
3913            .iter()
3914            .filter_map(|f| match &f.event {
3915                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
3916                _ => None,
3917            })
3918            .collect();
3919        assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
3920    }
3921
3922    #[tokio::test]
3923    async fn a_second_member_reads_the_public_channel_from_the_root() {
3924        // A member who holds the community_root (via an invite bundle, modeled
3925        // here by cloning the community) reads the owner's public-channel message
3926        // — public channels need no key delivery, they derive from the root.
3927        let (_tmp, _guard, _owner) = init_test_db();
3928        let relay = MemoryRelay::new();
3929        let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
3930        let general = community.channels[0].id;
3931        send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
3932
3933        // The "member" reconstructs the same read coordinates from the root.
3934        let member_view = community.clone();
3935        let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
3936        assert_eq!(page.len(), 1);
3937        assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
3938        assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
3939    }
3940
3941    // ── Two-actor end-to-end (the create → invite → join → message loop) ──────
3942
3943    async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
3944        fetch_channel(relay, community, channel, 100)
3945            .await
3946            .unwrap()
3947            .iter()
3948            .filter_map(|f| match &f.event {
3949                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
3950                _ => None,
3951            })
3952            .collect()
3953    }
3954
3955    #[tokio::test]
3956    async fn direct_invite_full_loop_owner_and_member_converse() {
3957        let (bed, owner, member) = TestBed::new();
3958
3959        // Owner creates a community, posts, and Direct-Invites the member's npub.
3960        bed.swap_to(&owner);
3961        let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
3962        let general = community.channels[0].id;
3963        send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
3964        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
3965
3966        // Member (a DIFFERENT account, no prior knowledge) finds + accepts the invite.
3967        bed.swap_to(&member);
3968        assert!(
3969            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
3970            "the member does not hold the community before joining"
3971        );
3972        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
3973        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
3974        assert_eq!(joined.id().0, community.id().0, "joined the same community");
3975        assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
3976        assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
3977
3978        // The member reads the owner's public-channel history and replies.
3979        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
3980        send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
3981
3982        // The owner reads the member's reply.
3983        bed.swap_to(&owner);
3984        assert_eq!(
3985            texts_in(&bed.relay, &community, &general).await,
3986            vec!["owner: welcome!", "member: thanks for the invite"],
3987            "both actors' messages interleave in ms order on the shared channel"
3988        );
3989
3990        // The Guestbook memberlist now folds both participants.
3991        let members = memberlist(&bed.relay, &community).await.unwrap();
3992        assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
3993        assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
3994        assert_eq!(members.len(), 2);
3995    }
3996
3997    #[tokio::test]
3998    async fn public_link_full_loop() {
3999        let (bed, owner, member) = TestBed::new();
4000
4001        bed.swap_to(&owner);
4002        let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
4003        let general = community.channels[0].id;
4004        send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
4005        // Mint a shareable link (a non-stock relay so the fragment carries it).
4006        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4007        assert!(link.url.starts_with("https://vectorapp.io/invite/"));
4008        assert!(link.url.contains('#'), "the fragment carries the token");
4009
4010        // Member joins purely from the URL string.
4011        bed.swap_to(&member);
4012        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
4013        assert_eq!(joined.id().0, community.id().0);
4014        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
4015    }
4016
4017    #[test]
4018    fn bundle_of_snapshots_the_held_icon() {
4019        let owner = Keys::generate();
4020        let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
4021        let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
4022        let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
4023        c.icon = Some(icon.clone());
4024        let bundle = bundle_of(&c, None, None, None);
4025        assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
4026    }
4027
4028    #[test]
4029    fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
4030        // follow_rekeys' fetch fan AND streamauth's plane registration share
4031        // this. A channel rekey rides the PRIOR root (CORD-06 D2), so the set
4032        // MUST include archived roots or an AUTH-gated relay never serves the
4033        // rotation crate → the channel stalls at its old epoch.
4034        let (_tmp, _guard, _owner) = init_test_db();
4035        let cur_root = [9u8; 32];
4036        let cid = crate::community::CommunityId([1u8; 32]);
4037        let cid_hex = cid.to_hex();
4038
4039        // No archives yet → just the current root.
4040        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
4041        assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
4042
4043        // Archive two prior roots (freshest-first ordering is asserted below).
4044        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
4045        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
4046        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
4047        assert_eq!(roots[0], cur_root, "current root leads");
4048        assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
4049        assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
4050        // Freshest-archived-first (epoch 1 before epoch 0).
4051        assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
4052
4053        // A stored root equal to the CURRENT one must not duplicate.
4054        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
4055        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
4056        assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
4057
4058        // Cap: many archives truncate to MAX_ADDRESSING_ROOTS.
4059        for e in 3..20u64 {
4060            crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
4061        }
4062        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
4063        assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
4064    }
4065
4066    #[tokio::test]
4067    async fn public_link_preview_shows_live_name_and_icon_without_joining() {
4068        let (bed, owner, member) = TestBed::new();
4069
4070        bed.swap_to(&owner);
4071        let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
4072        // The icon lives on the Control Plane, never in the bundle — publish it
4073        // as a metadata edition so the preview must FOLD to see it.
4074        let icon = control::ImageRef {
4075            url: "https://blossom.example/soap".into(),
4076            key: "k".into(),
4077            nonce: "n".into(),
4078            hash: "h".into(),
4079            extra: Default::default(),
4080        };
4081        let mut meta = community.metadata();
4082        meta.icon = Some(icon.clone());
4083        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
4084        // An any-host base — the naddr#fragment payload is domain-agnostic.
4085        let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
4086
4087        // A NON-member previews: the real name + the live icon, nothing persisted.
4088        bed.swap_to(&member);
4089        let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
4090        assert_eq!(preview.name, "Soapbox");
4091        assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
4092        assert!(
4093            crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
4094            "previewing must not persist a membership"
4095        );
4096    }
4097
4098    #[tokio::test]
4099    async fn a_previewed_join_reuses_the_verified_fold() {
4100        let (bed, owner, member) = TestBed::new();
4101        bed.swap_to(&owner);
4102        let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
4103        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4104
4105        bed.swap_to(&member);
4106        let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
4107        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
4108        assert_eq!(joined.id().0, community.id().0);
4109        assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
4110        // The slot was CONSUMED by the join — proving the handoff path ran (a
4111        // verify re-walk would have left the preview's entry in place).
4112        assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
4113    }
4114
4115    #[tokio::test]
4116    async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
4117        let (bed, owner, member) = TestBed::new();
4118        bed.swap_to(&owner);
4119        let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
4120        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4121
4122        bed.swap_to(&member);
4123        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
4124
4125        // Seed from zero: the stored fold equals the authoritative live fold.
4126        let session = SessionGuard::capture();
4127        assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the seed folds fresh events");
4128        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
4129        let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
4130        assert!(cursor > 0, "the cursor advanced past zero");
4131        let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
4132        let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
4133        assert_eq!(stored, live, "stored fold == live fold after the seed");
4134        assert!(stored.contains(&member.keys.public_key()));
4135
4136        // Nothing new on the plane → an idle re-sync folds nothing.
4137        assert!(sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty());
4138
4139        // The owner kicks the member; a CURSOR catch-up folds the kick in — no full walk.
4140        bed.swap_to(&owner);
4141        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
4142        bed.swap_to(&member);
4143        let session = SessionGuard::capture();
4144        assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the kick lands incrementally");
4145        assert!(
4146            !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
4147            "an owner kick removes the member from the stored fold"
4148        );
4149    }
4150
4151    #[tokio::test]
4152    async fn a_preview_then_revoke_still_refuses_the_join() {
4153        let (bed, owner, member) = TestBed::new();
4154        bed.swap_to(&owner);
4155        let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
4156        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4157
4158        // Member previews (warming the verified handoff), THEN the owner revokes.
4159        bed.swap_to(&member);
4160        let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
4161        assert_eq!(p.name, "RevokeRace");
4162        bed.swap_to(&owner);
4163        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
4164        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
4165
4166        // The join MUST refuse: the handoff skips only the root re-verify, never
4167        // the bundle re-fetch that carries the revocation gate.
4168        bed.swap_to(&member);
4169        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
4170        assert!(err.contains("revoked"), "got: {err}");
4171    }
4172
4173    #[tokio::test]
4174    async fn a_revoked_link_refuses_to_join() {
4175        let (bed, owner, member) = TestBed::new();
4176        bed.swap_to(&owner);
4177        let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
4178        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4179        // Owner retires the link (re-posts the coordinate as a tombstone).
4180        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
4181        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
4182
4183        bed.swap_to(&member);
4184        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
4185        assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
4186    }
4187
4188    #[tokio::test]
4189    async fn an_expired_direct_invite_refuses_to_join() {
4190        let (bed, owner, member) = TestBed::new();
4191        bed.swap_to(&owner);
4192        let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
4193        // Hand-mint an invite that expired in the past.
4194        let inviter = owner.keys.clone();
4195        let mut bundle = bundle_of(&community, Some(inviter.public_key()), Some(1_000), None);
4196        bundle.expires_at = Some(1_000); // unix ms, long past
4197        let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
4198        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
4199
4200        bed.swap_to(&member);
4201        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4202        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
4203        assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
4204    }
4205
4206    #[tokio::test]
4207    async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
4208        // The revocation-durability fix: if ANY signer-valid tombstone is among the
4209        // fetched events, refuse — even when a Live bundle is returned FIRST (the
4210        // production union has no newest-first sort, so a stale relay's Live can lead).
4211        let (bed, owner, member) = TestBed::new();
4212        bed.swap_to(&owner);
4213        let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
4214        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4215        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
4216
4217        // A relay union that hands back [Live, tombstone] — Live FIRST. Old
4218        // `events.first()` would join the Live; the scan-all fix must refuse.
4219        let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
4220
4221        bed.swap_to(&member);
4222        let err = accept_public_link(&union, &link.url).await.unwrap_err();
4223        assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
4224    }
4225
4226    #[test]
4227    fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
4228        // The accept-side DoS bound: from_bundle (which accept_bundle calls)
4229        // rejects a >256-channel bundle via validate() BEFORE the Vec allocation.
4230        // (The Direct-Invite wire path is additionally bounded by NIP-44's 64KB
4231        // cap, which trips even earlier — but the count guard is the real defense
4232        // for the single-layer public-link bundle.)
4233        let owner = Keys::generate();
4234        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
4235        let hex = crate::simd::hex::bytes_to_hex_32;
4236        let root = [0x11u8; 32];
4237        let mut bundle = CommunityInvite {
4238            community_id: hex(&identity.community_id.0),
4239            owner: hex(&identity.owner_xonly),
4240            owner_salt: hex(&identity.owner_salt),
4241            community_root: hex(&root),
4242            root_epoch: 0,
4243            channels: vec![],
4244            relays: vec!["wss://r".into()],
4245            name: "X".into(),
4246            icon: None,
4247            expires_at: None,
4248            creator_npub: None,
4249            label: None,
4250            extra: Default::default(),
4251        };
4252        bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
4253            .map(|i| {
4254                let mut id = [0u8; 32];
4255                id[..8].copy_from_slice(&(i as u64).to_be_bytes());
4256                invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
4257            })
4258            .collect();
4259        assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
4260    }
4261
4262    #[tokio::test]
4263    async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
4264        // The SessionGuard straddle: a public-link accept fetches then saves. If the
4265        // account swaps in that window, the join must abort — never write A's
4266        // community into B's DB. SwapMidFetch bumps the session generation during
4267        // the fetch await, exactly as a real swap_session would.
4268        let (bed, owner, member) = TestBed::new();
4269        bed.swap_to(&owner);
4270        let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
4271        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4272        // A fresh swap-injecting transport holding the same bundle event.
4273        let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
4274        swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
4275
4276        bed.swap_to(&member);
4277        let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
4278        assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
4279        assert!(
4280            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
4281            "the aborted join wrote nothing to the (member) account DB"
4282        );
4283    }
4284
4285    #[tokio::test]
4286    async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
4287        // The owner is derived from the self-certifying community_id, so the
4288        // memberlist includes them independent of any Guestbook fetch.
4289        let (_tmp, _guard, owner) = init_test_db();
4290        let relay = MemoryRelay::new();
4291        let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
4292        // A memberlist over an EMPTY guestbook (fetch a community-relay-less view)
4293        // still contains the owner.
4294        let empty = MemoryRelay::new();
4295        let members = memberlist(&empty, &community).await.unwrap();
4296        assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
4297    }
4298
4299    #[tokio::test]
4300    async fn an_expiring_minted_invite_refuses_after_the_deadline() {
4301        // The mint path can now produce an expiring invite, and the accept gate
4302        // trips on it (end-to-end through the real service, not a hand-built bundle).
4303        let (bed, owner, member) = TestBed::new();
4304        bed.swap_to(&owner);
4305        let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
4306        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
4307            .await
4308            .unwrap();
4309
4310        bed.swap_to(&member);
4311        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4312        assert!(
4313            accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
4314            "a minted expiring invite refuses past its deadline"
4315        );
4316    }
4317
4318    #[tokio::test]
4319    async fn a_member_who_leaves_drops_from_the_memberlist() {
4320        let (bed, owner, member) = TestBed::new();
4321        bed.swap_to(&owner);
4322        let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
4323        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
4324
4325        bed.swap_to(&member);
4326        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4327        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
4328        // Let the leave land strictly after the join.
4329        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
4330        leave_community(&bed.relay, &joined).await.unwrap();
4331
4332        bed.swap_to(&owner);
4333        let members = memberlist(&bed.relay, &community).await.unwrap();
4334        assert!(members.contains(&owner.keys.public_key()));
4335        assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
4336    }
4337
4338    #[tokio::test]
4339    async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
4340        // Multi-account isolation: after the swap, the member's DB holds nothing
4341        // of the owner's community — the dual-stack storage is per-account.
4342        let (bed, owner, member) = TestBed::new();
4343        bed.swap_to(&owner);
4344        let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
4345        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
4346
4347        bed.swap_to(&member);
4348        assert!(
4349            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
4350            "the owner's community must be invisible in the member's account DB"
4351        );
4352        assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
4353    }
4354
4355    // ── Live control-follow ──────────────────────────────────────────────────
4356
4357    /// Publish an owner-grammar channel edition straight to the control plane,
4358    /// signed by `signer` (the owner for a legit edit, a stranger for the
4359    /// authority test). `version`/`deleted` drive add-vs-rename-vs-delete.
4360    /// The entity's current head `self_hash` on the relay (highest version wins),
4361    /// so a helper can chain a new edition the way a real owner client does.
4362    async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
4363        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4364        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
4365        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
4366        let mut head: Option<(u64, [u8; 32])> = None;
4367        for w in &wraps {
4368            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
4369                if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
4370                    head = Some((ed.version, ed.self_hash));
4371                }
4372            }
4373        }
4374        head.map(|(_, h)| h)
4375    }
4376
4377    async fn publish_channel_edition(
4378        relay: &MemoryRelay,
4379        community: &CommunityV2,
4380        signer: &Keys,
4381        channel_id: &ChannelId,
4382        name: &str,
4383        private: bool,
4384        version: u64,
4385        deleted: bool,
4386    ) {
4387        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4388        let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
4389        let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
4390        let content = serde_json::to_string(&meta).unwrap();
4391        let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
4392        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4393        relay.publish(&wrap, &community.relays).await.unwrap();
4394    }
4395
4396    /// Publish an owner-grammar community-metadata edition (rename etc.), chained
4397    /// to the current relay head like a real owner client.
4398    async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
4399        publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
4400    }
4401
4402    /// As [`publish_community_meta`] with an explicit timestamp, for tests that need
4403    /// relay-side newest-first ordering (paging/eviction scenarios).
4404    async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
4405        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4406        let prev = head_hash_on_relay(relay, community, &community.id().0).await;
4407        let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
4408        let content = serde_json::to_string(&meta).unwrap();
4409        let rumor = control::build_edition_rumor(signer.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, version, prev.as_ref(), &content, at_secs, None);
4410        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
4411        relay.publish(&wrap, &community.relays).await.unwrap();
4412    }
4413
4414    #[test]
4415    fn metadata_apply_captures_undriven_fields_for_republish() {
4416        let owner = Keys::generate();
4417        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
4418        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
4419        let general = held.channels[0].id;
4420
4421        // A foreign vsk-0 head carrying custom + unknown fields folds them in…
4422        let mut custom = serde_json::Map::new();
4423        custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
4424        let mut extra = serde_json::Map::new();
4425        extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
4426        let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
4427        assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
4428        assert_eq!(held.meta_custom, Some(custom.clone()));
4429        assert_eq!(held.meta_extra, extra);
4430        // …and the next local edit's base document republishes them verbatim.
4431        assert_eq!(held.metadata().custom, Some(custom));
4432        assert_eq!(held.metadata().extra, held.meta_extra);
4433
4434        // Same contract for a vsk-2 channel head (voice included).
4435        let mut ch_custom = serde_json::Map::new();
4436        ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
4437        let ch_meta = control::ChannelMetadata {
4438            name: "general".into(),
4439            private: false,
4440            voice: Some(true),
4441            deleted: None,
4442            custom: Some(ch_custom.clone()),
4443            extra: Default::default(),
4444        };
4445        assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
4446        let ch = held.channel(&general).unwrap();
4447        assert_eq!(ch.voice, Some(true));
4448        assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
4449        let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
4450        assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
4451        assert_eq!(rename.custom, Some(ch_custom));
4452    }
4453
4454    #[test]
4455    fn community_metadata_apply_sets_and_clears_images() {
4456        let owner = Keys::generate();
4457        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
4458        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
4459
4460        let icon = control::ImageRef {
4461            url: "https://blossom.example/i".into(),
4462            key: "k".into(),
4463            nonce: "n".into(),
4464            hash: "h".into(),
4465            extra: Default::default(),
4466        };
4467        let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
4468        assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
4469        assert_eq!(held.icon.as_ref(), Some(&icon));
4470
4471        // An edition is the FULL document: a head without the icon removes it.
4472        let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
4473        assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
4474        assert_eq!(held.icon, None);
4475    }
4476
4477    /// Publish a Role edition (vsk 1) signed by `signer`, chained to the current head.
4478    async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
4479        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4480        let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
4481        let prev = head_hash_on_relay(relay, community, &role_id).await;
4482        let content = crate::community::v2::roles::role_content_json(role).unwrap();
4483        let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, None);
4484        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4485        relay.publish(&wrap, &community.relays).await.unwrap();
4486    }
4487
4488    /// Publish a Grant edition (vsk 3) signed by `signer`, at grant_locator(cid, member).
4489    async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
4490        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4491        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
4492        let prev = head_hash_on_relay(relay, community, &eid).await;
4493        let grant = MemberGrant { member: member.to_hex(), role_ids };
4494        let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
4495        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, None);
4496        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4497        relay.publish(&wrap, &community.relays).await.unwrap();
4498    }
4499
4500    /// Publish a Banlist edition (vsk 4) signed by `signer`, at banlist_locator(cid).
4501    async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
4502        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4503        let eid = crate::community::v2::derive::banlist_locator(community.id());
4504        let prev = head_hash_on_relay(relay, community, &eid).await;
4505        let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
4506        let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, None);
4507        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4508        relay.publish(&wrap, &community.relays).await.unwrap();
4509    }
4510
4511    fn admin_role(role_id: &str, perms: u64) -> Role {
4512        Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
4513    }
4514
4515    // ── CORD-04 §1 author-aware fold: a seat-holder (holds community_root, so can seal
4516    // any control edition) must not be able to SUPPRESS a role or grant by forging a
4517    // higher version at its coordinate. Owner-only signers mask this entirely, so every
4518    // attacker below signs as a NON-owner member.
4519
4520    #[tokio::test]
4521    async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
4522        let (bed, owner, attacker) = TestBed::new();
4523        bed.swap_to(&owner);
4524        let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
4525        let victim = Keys::generate().public_key();
4526        grant_admin(&bed.relay, &community, &victim).await.unwrap();
4527
4528        // The admin role sits at a deterministic, publicly-computable coordinate.
4529        let admin_rid = fetch_authority(&bed.relay, &community)
4530            .await
4531            .roles
4532            .roles
4533            .iter()
4534            .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
4535            .unwrap()
4536            .role_id
4537            .clone();
4538        // Attacker forges v2 of that exact role, stripping its powers.
4539        publish_role(
4540            &bed.relay,
4541            &community,
4542            &attacker.keys,
4543            &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
4544            2,
4545        )
4546        .await;
4547
4548        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
4549        assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
4550        assert!(
4551            authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
4552            "the floor advances only to the AUTHORIZED head (owner v1)"
4553        );
4554        assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
4555    }
4556
4557    #[tokio::test]
4558    async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
4559        let (bed, owner, attacker) = TestBed::new();
4560        bed.swap_to(&owner);
4561        let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
4562        let victim = Keys::generate();
4563        grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
4564
4565        // Attacker forges a higher-version EMPTY grant at the victim's grant coordinate.
4566        publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
4567
4568        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
4569        assert!(
4570            authority.roles.is_admin(&victim.public_key().to_hex()),
4571            "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
4572        );
4573    }
4574
4575    #[tokio::test]
4576    async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
4577        let (bed, owner, attacker) = TestBed::new();
4578        bed.swap_to(&owner);
4579        let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
4580        let victim = Keys::generate().public_key();
4581        grant_admin(&bed.relay, &community, &victim).await.unwrap();
4582
4583        // Low-id roles that WOULD evict the admin from a pre-authorize cap — but they're
4584        // unauthorized, so the post-authorize cap never sees them.
4585        for i in 0u8..6 {
4586            let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
4587            publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4588        }
4589
4590        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
4591        assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
4592        assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
4593    }
4594
4595    /// A canonical (order-independent) fingerprint of an AuthoritySet's authorized
4596    /// roster + banlist — two clients converge iff these match.
4597    fn authority_fingerprint(a: &AuthoritySet) -> String {
4598        let mut roles = a.roles.roles.clone();
4599        roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
4600        let mut grants = a.roles.grants.clone();
4601        for g in &mut grants {
4602            g.role_ids.sort();
4603        }
4604        grants.sort_by(|x, y| x.member.cmp(&y.member));
4605        let banned: Vec<&String> = a.banned.iter().collect();
4606        serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
4607    }
4608
4609    #[tokio::test]
4610    async fn the_v2_authority_fold_is_order_independent() {
4611        // THE core consensus property: two honest clients that receive the SAME
4612        // control editions in DIFFERENT arrival orders must resolve the IDENTICAL
4613        // authorized roster + banlist (author-aware select_authorized + banlist
4614        // fold + cap, all deterministic). A divergence here would fork the
4615        // community's moderation state between honest members.
4616        let (bed, owner, _a) = TestBed::new();
4617        bed.swap_to(&owner);
4618        let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
4619
4620        // A rich control plane: two admins, an extra role, two grants (one of them a
4621        // grant to a member the owner then bans), a banlist, a rename, a channel.
4622        let admin1 = Keys::generate().public_key();
4623        let admin2 = Keys::generate().public_key();
4624        grant_admin(&bed.relay, &community, &admin1).await.unwrap();
4625        grant_admin(&bed.relay, &community, &admin2).await.unwrap();
4626        let mod_rid = "5c".repeat(32);
4627        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
4628        let member = Keys::generate().public_key();
4629        publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
4630        let banned_member = Keys::generate().public_key();
4631        publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
4632        set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
4633        let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
4634        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
4635        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
4636
4637        let editions = fetch_control(&bed.relay, &community).await;
4638        let floors = load_floors(&community);
4639        assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
4640
4641        let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
4642
4643        // Fold under many arrival permutations: reversed, and several deterministic
4644        // rotations/interleavings. Every one must match the baseline.
4645        let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
4646        let mut rev = editions.clone();
4647        rev.reverse();
4648        orders.push(rev);
4649        for shift in [1usize, 3, 5, 7] {
4650            let n = editions.len();
4651            orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
4652        }
4653        // A deterministic "shuffle": interleave from both ends.
4654        let mut zip = Vec::with_capacity(editions.len());
4655        let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
4656        while lo <= hi {
4657            zip.push(editions[lo as usize].clone());
4658            if lo != hi {
4659                zip.push(editions[hi as usize].clone());
4660            }
4661            lo += 1;
4662            hi -= 1;
4663        }
4664        orders.push(zip);
4665
4666        for (i, order) in orders.iter().enumerate() {
4667            let got = authority_fingerprint(&fold_authority(&community, order, &floors));
4668            assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
4669        }
4670        // Sanity: the fingerprint reflects real state (the banned member is out, the
4671        // honest admins are in).
4672        assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
4673        assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
4674    }
4675
4676    /// A transport that ACKs publishes but ERRORS every fetch — a relay outage / withhold.
4677    struct FetchErrors(MemoryRelay);
4678    #[async_trait::async_trait]
4679    impl crate::community::transport::Transport for FetchErrors {
4680        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
4681        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
4682            self.0.publish(e, r).await
4683        }
4684        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
4685            Err("relay down".to_string())
4686        }
4687        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
4688            self.0.publish_durable(e, r).await
4689        }
4690    }
4691
4692    #[tokio::test]
4693    async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
4694        let (bed, owner, victim) = TestBed::new();
4695        bed.swap_to(&owner);
4696        let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
4697        let victim_hex = victim.keys.public_key().to_hex();
4698        // A ban is persisted locally (as a completed set_banlist + follow leaves it).
4699        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4700        crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
4701
4702        // A relay that ERRORS on fetch must degrade FAIL-SAFE: retain the ban, never
4703        // return an empty banlist (which would silently un-ban on withheld data).
4704        let down = FetchErrors(MemoryRelay::new());
4705        let view = fetch_authority(&down, &community).await;
4706        assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
4707    }
4708
4709    #[tokio::test]
4710    async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
4711        let (bed, owner, _m) = TestBed::new();
4712        bed.swap_to(&owner);
4713        let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
4714        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4715        let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
4716        let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
4717
4718        // Full state on relay1: an Admin role + two grants → both fold + persist as admins.
4719        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4720        publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
4721        publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
4722        let session = crate::state::SessionGuard::capture();
4723        follow_control(&bed.relay, &community, &session).await.unwrap();
4724        assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
4725
4726        // relay2 serves A's grant but NOT the role (aged out of the window): the fold
4727        // drops both admins yet raises no gap. The completeness gate must RETAIN the
4728        // stored roster rather than persist the lossy one.
4729        let relay2 = MemoryRelay::new();
4730        publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
4731        follow_control(&relay2, &community, &session).await.unwrap();
4732        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
4733        assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
4734    }
4735
4736    #[tokio::test]
4737    async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
4738        // CORD-04 §5: an admin holding MANAGE_METADATA renames the community; once the
4739        // owner revokes the grant, the (now unauthorized) admin's further edit drops
4740        // and the name holds at the last authorized state.
4741        let (_tmp, _guard, owner) = init_test_db();
4742        let relay = MemoryRelay::new();
4743        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
4744        let admin = Keys::generate();
4745        let rid = "a1".repeat(32);
4746        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
4747        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
4748        publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
4749
4750        let session = SessionGuard::capture();
4751        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
4752        assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
4753
4754        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke
4755        publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
4756        let _ = follow_control(&relay, &community, &session).await.unwrap();
4757        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4758        assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
4759    }
4760
4761    #[tokio::test]
4762    async fn a_roleless_member_cannot_edit_metadata() {
4763        let (_tmp, _guard, _owner) = init_test_db();
4764        let relay = MemoryRelay::new();
4765        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
4766        let stranger = Keys::generate();
4767        publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
4768        let session = SessionGuard::capture();
4769        assert!(
4770            follow_control(&relay, &community, &session).await.unwrap().is_none(),
4771            "a roleless member's metadata edit never folds"
4772        );
4773    }
4774
4775    #[tokio::test]
4776    async fn a_self_signed_grant_is_not_authority() {
4777        // The self-promotion defense: a member self-signs both a role and a grant of
4778        // it to themselves. authorize_delegation drops both (their signer never traces
4779        // to the owner), so their metadata edit stays unauthorized.
4780        let (_tmp, _guard, _owner) = init_test_db();
4781        let relay = MemoryRelay::new();
4782        let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
4783        let rogue = Keys::generate();
4784        let rid = "b2".repeat(32);
4785        publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4786        publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
4787        publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
4788        let session = SessionGuard::capture();
4789        assert!(
4790            follow_control(&relay, &community, &session).await.unwrap().is_none(),
4791            "a self-signed grant confers no authority"
4792        );
4793    }
4794
4795    #[tokio::test]
4796    async fn the_banlist_is_enforced_only_from_a_ban_holder() {
4797        let (_tmp, _guard, owner) = init_test_db();
4798        let relay = MemoryRelay::new();
4799        let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
4800        let target = "cc".repeat(32);
4801
4802        // A non-BAN-holder's banlist edition is folded but NOT enforced.
4803        let rogue = Keys::generate();
4804        publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
4805        let floors = load_floors(&community);
4806        let editions = fetch_control(&relay, &community).await;
4807        let authority = fold_authority(&community, &editions, &floors);
4808        assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
4809
4810        // The owner (supreme, holds BAN) bans the target: now enforced.
4811        publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
4812        let editions = fetch_control(&relay, &community).await;
4813        let authority = fold_authority(&community, &editions, &floors);
4814        assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
4815    }
4816
4817    #[tokio::test]
4818    async fn a_banned_admin_loses_all_authority() {
4819        // CORD-04 §4: a banned npub vanishes — even holding an un-stripped grant, a
4820        // banned admin's authority is dropped and their edits refused.
4821        let (_tmp, _guard, owner) = init_test_db();
4822        let relay = MemoryRelay::new();
4823        let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
4824        let admin = Keys::generate();
4825        let rid = "e5".repeat(32);
4826        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
4827        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
4828        publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; // ban, grant left intact
4829        publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
4830
4831        let session = SessionGuard::capture();
4832        assert!(
4833            follow_control(&relay, &community, &session).await.unwrap().is_none(),
4834            "a banned admin's edit is dropped even with an unstripped grant"
4835        );
4836        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4837        assert!(authority.banned.contains(&admin.public_key().to_hex()));
4838        assert!(
4839            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
4840            "a banned admin holds no bit"
4841        );
4842    }
4843
4844    #[tokio::test]
4845    async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
4846        // CORD-04 §3/§5: BAN needs the bit AND a strict outrank of the target. A mod
4847        // (pos 2, holds BAN) can ban a lower member but NOT a superior admin (pos 1)
4848        // and NOT the owner (supreme, unbannable).
4849        let (_tmp, _guard, owner) = init_test_db();
4850        let relay = MemoryRelay::new();
4851        let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
4852        let admin = Keys::generate();
4853        let moder = Keys::generate();
4854        let stranger = Keys::generate();
4855        let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
4856        publish_role(&relay, &community, &owner, &Role { role_id: admin_rid.clone(), name: "Admin".into(), position: 1, permissions: Permissions(Permissions::ADMIN_ALL), scope: RoleScope::Server, color: 0 }, 1).await;
4857        publish_role(&relay, &community, &owner, &Role { role_id: mod_rid.clone(), name: "Mod".into(), position: 2, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
4858        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
4859        publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
4860        publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
4861
4862        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4863        assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
4864        assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
4865        assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
4866    }
4867
4868    #[tokio::test]
4869    async fn an_unauthorized_higher_banlist_cannot_unban() {
4870        // CORD-04 §4 anti-roster fail-CLOSED: a rogue's higher-version empty banlist
4871        // must not erase the owner's ban (author-aware head selection + persisted
4872        // banlist retention).
4873        let (_tmp, _guard, owner) = init_test_db();
4874        let relay = MemoryRelay::new();
4875        let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
4876        let target = "cc".repeat(32);
4877        publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
4878        let session = SessionGuard::capture();
4879        follow_control(&relay, &community, &session).await.unwrap(); // persists the ban
4880
4881        let rogue = Keys::generate();
4882        publish_banlist(&relay, &community, &rogue, &[], 2).await; // unauthorized higher, empty
4883        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4884        assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
4885    }
4886
4887    #[tokio::test]
4888    async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
4889        // CORD-02 §8: create publishes the 13302; a fresh device (community dropped
4890        // locally, the 13302 + genesis still on the relay) rehydrates it on sync.
4891        let (_tmp, _guard, _owner) = init_test_db();
4892        let relay = MemoryRelay::new();
4893        let relays = vec!["wss://r".to_string()];
4894        let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
4895        crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
4896        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
4897
4898        let rehydrated = sync_community_list(&relay, &relays).await.unwrap();
4899        assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
4900        assert_eq!(rehydrated[0].id().0, community.id().0);
4901        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
4902    }
4903
4904    #[tokio::test]
4905    async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
4906        let (_tmp, _guard, _owner) = init_test_db();
4907        let relay = MemoryRelay::new();
4908        let relays = vec!["wss://r".to_string()];
4909        let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
4910        leave_community(&relay, &community).await.unwrap(); // tombstones the 13302 + deletes
4911
4912        let rehydrated = sync_community_list(&relay, &relays).await.unwrap();
4913        assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
4914    }
4915
4916    #[tokio::test]
4917    async fn accepting_the_same_bundle_twice_is_idempotent() {
4918        // A bot restart or a duplicate invite delivery: accepting the SAME bundle
4919        // again must upsert cleanly — same community_id, no duplicate channels, no
4920        // corruption, the keys unchanged.
4921        let (bed, owner, member) = TestBed::new();
4922        bed.swap_to(&owner);
4923        let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
4924        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
4925        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4926        let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4927
4928        bed.swap_to(&member);
4929        let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
4930        let channels_after_first = first.channels.len();
4931        let root_after_first = first.community_root;
4932
4933        // Accept the identical bundle again (restart / redelivery).
4934        let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
4935        assert_eq!(second.id().0, first.id().0, "same community_id");
4936        assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
4937        assert_eq!(second.community_root, root_after_first, "root unchanged");
4938
4939        // The persisted state is a single clean community with the expected channels.
4940        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4941        assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
4942        assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
4943    }
4944
4945    #[tokio::test]
4946    async fn a_severed_member_can_be_unbanned_and_re_admitted() {
4947        // The full moderation HEAL lifecycle: ban (banlist + grant strip + refound)
4948        // severs a member; the owner then unbans + sends a FRESH invite carrying the
4949        // NEW root; the member rejoins at the new epoch and converses again. Proves
4950        // a ban is reversible end-to-end, not a one-way door.
4951        let (bed, owner, member) = TestBed::new();
4952        bed.swap_to(&owner);
4953        let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
4954        let general = community.channels[0].id;
4955        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
4956        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
4957
4958        bed.swap_to(&member);
4959        let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4960        let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
4961        assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
4962
4963        // Owner bans the member (CORD-04 §6 three-removal) → refound severs them.
4964        bed.swap_to(&owner);
4965        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
4966        grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
4967        community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
4968        assert_eq!(community.root_epoch, Epoch(1));
4969        send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
4970
4971        // The member's follow concludes severance (no blob at the new epoch).
4972        bed.swap_to(&member);
4973        let session = SessionGuard::capture();
4974        assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
4975
4976        // Owner unbans + re-invites: build the fresh epoch-1 bundle (accept it
4977        // directly, so the test picks the NEW invite unambiguously rather than an
4978        // arbitrary one of the two pending 3313s).
4979        bed.swap_to(&owner);
4980        set_banlist(&bed.relay, &community, &[]).await.unwrap();
4981        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4982        assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
4983        let fresh_bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4984
4985        // Member accepts the fresh invite → rejoins at epoch 1, reads current + posts.
4986        bed.swap_to(&member);
4987        let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
4988        assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
4989        assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
4990        let seen = texts_in(&bed.relay, &rejoined, &general).await;
4991        assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
4992        send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
4993
4994        bed.swap_to(&owner);
4995        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4996        assert!(
4997            texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
4998            "the re-admitted member converses again at the new epoch"
4999        );
5000        // And they're back in the memberlist.
5001        let members = memberlist(&bed.relay, &community).await.unwrap();
5002        assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
5003    }
5004
5005    #[tokio::test]
5006    async fn dissolution_blocks_a_join() {
5007        // CORD-02 §9: the owner dissolves; a would-be joiner resolves the grave and
5008        // refuses to join.
5009        let (bed, owner, member) = TestBed::new();
5010        bed.swap_to(&owner);
5011        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
5012        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5013        let bundle_json = serde_json::to_string(&bundle).unwrap();
5014        dissolve_community(&bed.relay, &community).await.unwrap();
5015        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
5016
5017        bed.swap_to(&member);
5018        let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
5019        assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
5020    }
5021
5022    #[tokio::test]
5023    async fn only_the_owner_can_dissolve() {
5024        let (bed, owner, member) = TestBed::new();
5025        bed.swap_to(&owner);
5026        let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
5027        bed.swap_to(&member);
5028        assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
5029        assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
5030    }
5031
5032    #[tokio::test]
5033    async fn a_foreign_tombstone_is_not_death() {
5034        // A non-owner sealing the dissolved plane is noise (verify_dissolved is
5035        // owner-gated), so the community is not treated as dead.
5036        let (_tmp, _guard, _owner) = init_test_db();
5037        let relay = MemoryRelay::new();
5038        let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
5039        let rogue = Keys::generate();
5040        let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
5041        let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
5042        relay.publish(&wrap, &community.relays).await.unwrap();
5043        assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
5044    }
5045
5046    #[tokio::test]
5047    async fn a_public_channel_reads_history_across_a_refounding() {
5048        // CORD-03 §3: after a Refounding rolls the base root, a Public channel's
5049        // pre-rotation messages stay readable (the prior epoch's root is archived and
5050        // the read fans out across held epochs).
5051        let (_tmp, _guard, _owner) = init_test_db();
5052        let relay = MemoryRelay::new();
5053        let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
5054        let general = community.channels[0].id;
5055        send_message(&relay, &community, &general, "before the refounding").await.unwrap();
5056
5057        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
5058        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
5059        send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
5060
5061        let texts = texts_in(&relay, &refounded, &general).await;
5062        assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
5063        assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
5064    }
5065
5066    #[tokio::test]
5067    async fn refounding_aborts_when_control_state_is_withheld() {
5068        // B1 coverage gate (CORD-06 §3): a relay serving none of the committed control
5069        // heads must ABORT the Refounding — never silently drop state (e.g. unban a
5070        // member at the new epoch a fresh joiner bootstraps).
5071        let (_tmp, _guard, owner) = init_test_db();
5072        let relay = MemoryRelay::new();
5073        let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
5074        publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
5075        let session = SessionGuard::capture();
5076        follow_control(&relay, &community, &session).await.unwrap(); // seed the banlist floor
5077
5078        // Re-point the held community to an EMPTY relay + save, so the Refounding (which
5079        // reloads fresh state) fetches none of the committed heads.
5080        let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5081        moved.relays = vec!["wss://empty".into()];
5082        crate::db::community::save_community_v2(&moved).unwrap();
5083
5084        let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
5085        assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
5086        assert_eq!(
5087            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
5088            Epoch(0),
5089            "the epoch did NOT advance (zero published state)"
5090        );
5091    }
5092
5093    #[tokio::test]
5094    async fn refounding_rolls_the_root_and_severs_a_removed_member() {
5095        // CORD-06 §3: the owner re-founds, removing a member. The base root rolls, the
5096        // epoch advances, and the removed member's rekey-follow concludes they're cut.
5097        let (bed, owner, member) = TestBed::new();
5098        bed.swap_to(&owner);
5099        let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
5100        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5101        let bundle_json = serde_json::to_string(&bundle).unwrap();
5102        bed.swap_to(&member);
5103        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5104
5105        bed.swap_to(&owner);
5106        let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
5107        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
5108        assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
5109        // The owner still reads the compacted control plane at the new epoch.
5110        let session = SessionGuard::capture();
5111        assert_eq!(
5112            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
5113            Epoch(1),
5114            "the owner committed the new epoch"
5115        );
5116
5117        // The removed member, following rekeys, is severed (no blob in the rotation).
5118        bed.swap_to(&member);
5119        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
5120        assert!(follow.self_removed, "the removed member is cut by the re-founding");
5121    }
5122
5123    #[tokio::test]
5124    async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
5125        // CORD-06 §Authority: a Refounding requires BAN, not owner-identity. A
5126        // non-owner admin granted BAN CAN re-found (and every member follows it —
5127        // see the receive-side test), but the "strictly outrank every removed
5128        // target" rule still holds: they can't use it to evict the owner.
5129        let (bed, owner, member) = TestBed::new();
5130        bed.swap_to(&owner);
5131        let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
5132        let rid = "b0".repeat(32);
5133        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
5134        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
5135        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5136        let bundle_json = serde_json::to_string(&bundle).unwrap();
5137        bed.swap_to(&member);
5138        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5139        // Fold the roster so this member's own DB reflects their BAN grant (the
5140        // authority check reads the folded Roster, not the bundle).
5141        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
5142        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
5143        // Can't evict the owner (no one outranks the owner).
5144        assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
5145        // But CAN re-found removing a plain member they outrank (here, nobody).
5146        assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
5147    }
5148
5149    #[tokio::test]
5150    async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
5151        // A BAN-holding ADMIN (not the owner) re-founds, and every member must
5152        // follow it — owner-only receive silently strands members whose community
5153        // was refounded by an admin (CORD-06 §Authority: "a Refounding requires
5154        // BAN", checked against the folded Roster).
5155        let (bed, owner, me) = TestBed::new();
5156        let admin = Keys::generate();
5157        bed.swap_to(&owner);
5158        let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
5159        let rid = "b0".repeat(32);
5160        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
5161        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
5162
5163        // I (a plain member) join, then fold the roster so I know the admin holds BAN.
5164        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5165        let bundle_json = serde_json::to_string(&bundle).unwrap();
5166        bed.swap_to(&me);
5167        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5168        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
5169        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
5170
5171        // The admin re-founds keeping the owner + me — the owner must always be a
5172        // recipient of a non-owner Refounding.
5173        let new_root = [0xC7; 32];
5174        publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
5175
5176        let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
5177            .expect("an authorized admin's Refounding is adopted");
5178        assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
5179        assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
5180    }
5181
5182    #[tokio::test]
5183    async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
5184        // Authority escalation: a BAN-admin can't use a Refounding to evict the
5185        // OWNER (no one outranks the owner). Excluding them makes the rotation
5186        // inadmissible — members fork-reject it rather than migrate to the coup.
5187        let (bed, owner, me) = TestBed::new();
5188        let admin = Keys::generate();
5189        bed.swap_to(&owner);
5190        let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
5191        let rid = "b0".repeat(32);
5192        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
5193        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
5194
5195        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5196        let bundle_json = serde_json::to_string(&bundle).unwrap();
5197        bed.swap_to(&me);
5198        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5199        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
5200        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
5201
5202        // The admin re-founds delivering to me but NOT the owner — a takeover.
5203        publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
5204        let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
5205        assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
5206    }
5207
5208    #[tokio::test]
5209    async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
5210        // Authority escalation: two equal-rank BAN-admins — neither strictly
5211        // outranks the other, so one can't Refound the other out. Excluding a
5212        // peer makes the rotation inadmissible.
5213        let (bed, owner, me) = TestBed::new();
5214        let admin_a = Keys::generate();
5215        let admin_b = Keys::generate(); // the peer admin the rotation excludes.
5216        bed.swap_to(&owner);
5217        let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
5218        let rid = "b0".repeat(32);
5219        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
5220        // Both A and B hold the SAME role (same position 1) → peers.
5221        publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
5222        publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
5223
5224        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5225        let bundle_json = serde_json::to_string(&bundle).unwrap();
5226        bed.swap_to(&me);
5227        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5228        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
5229        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
5230
5231        // Admin A re-founds keeping the owner + me but EXCLUDING peer admin B.
5232        publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
5233
5234        let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
5235        assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
5236    }
5237
5238    #[tokio::test]
5239    async fn a_retried_refounding_reuses_the_same_root() {
5240        // B1 idempotency: minting for the same (scope, epoch) twice yields the SAME
5241        // root, so a retried Refounding re-delivers one root — never a double-mint fork.
5242        let (_tmp, _guard, _owner) = init_test_db();
5243        let relay = MemoryRelay::new();
5244        let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
5245        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5246        let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
5247        let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
5248        assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
5249    }
5250
5251    #[tokio::test]
5252    async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
5253        // CORD-05 §5: the Registry is the Public/Private source of truth. Minting a
5254        // link publishes it (Public); retiring the last link empties it (Private).
5255        let (_tmp, _guard, _owner) = init_test_db();
5256        let relay = MemoryRelay::new();
5257        let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
5258        assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
5259
5260        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
5261        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
5262        let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
5263        assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
5264
5265        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
5266        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
5267        assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
5268        let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
5269        assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
5270    }
5271
5272    #[tokio::test]
5273    async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
5274        // The CREATE_INVITE gate: a rogue publishing a registry can't fake Public.
5275        let (_tmp, _guard, owner) = init_test_db();
5276        let relay = MemoryRelay::new();
5277        let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
5278        let rogue = Keys::generate();
5279        // Rogue publishes a registry edition at THEIR coordinate with a fake signer.
5280        let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
5281        let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
5282        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5283        let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
5284        let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
5285        relay.publish(&wrap, &community.relays).await.unwrap();
5286        let _ = owner;
5287        assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
5288    }
5289
5290    #[tokio::test]
5291    async fn full_lifecycle_e2e() {
5292        // The whole stack end to end across two accounts: create -> Public link ->
5293        // owner grants an admin -> member joins + reads history -> admin edits metadata
5294        // (authorized fold) -> owner bans the member (CORD-04 §6: banlist + strip +
5295        // Refounding) -> the banned member is severed AND stays banned across the new
5296        // epoch -> pre-ban history still reads -> owner dissolves -> sealed.
5297        let (bed, owner, member) = TestBed::new();
5298
5299        bed.swap_to(&owner);
5300        let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
5301        let general = community.channels[0].id;
5302        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
5303
5304        // Public link → the community reads Public.
5305        let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
5306        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
5307
5308        // Owner defines + grants an Admin role (MANAGE_METADATA among the bits).
5309        let rid = "aa".repeat(32);
5310        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
5311        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
5312
5313        // Member joins from the bundle + reads the owner's message.
5314        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5315        let bundle_json = serde_json::to_string(&bundle).unwrap();
5316        bed.swap_to(&member);
5317        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5318        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
5319        // The admin renames the community.
5320        publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
5321
5322        // Owner follows: the admin's rename folds (authorized).
5323        bed.swap_to(&owner);
5324        let session = SessionGuard::capture();
5325        let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
5326        assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
5327
5328        // Ban the member (the three-removal composition, in order).
5329        set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
5330        grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
5331        let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
5332        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
5333        // The ban survives the Refounding (the banlist head compacted forward).
5334        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
5335        assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
5336        // Pre-ban history still reads across the new epoch.
5337        assert!(
5338            texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
5339            "pre-refounding history stays readable"
5340        );
5341
5342        // The banned member's rekey-follow concludes they're severed.
5343        bed.swap_to(&member);
5344        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
5345        assert!(follow.self_removed, "the banned member is cryptographically cut");
5346
5347        // Owner dissolves → sealed.
5348        bed.swap_to(&owner);
5349        dissolve_community(&bed.relay, &refounded).await.unwrap();
5350        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
5351    }
5352
5353    /// The deep two-account e2e the way a real deployment runs: owner (A) + member (B)
5354    /// over one shared relay, create → channels (public + private) → converse both ways →
5355    /// persist (get_messages-level) → react/edit/delete → moderate (ban/unban) → dissolve.
5356    /// Every account, community, channel, and action is LOGGED (run with --nocapture) so it
5357    /// doubles as a reference transcript and a re-runnable regression.
5358    #[tokio::test]
5359    async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
5360        // A member forges a higher-version role edition at the admin coordinate before a
5361        // refounding. The compaction must carry the AUTHORIZED floor head, not the
5362        // author-blind version tip — else the forgery is re-anchored, honest folders drop
5363        // it, and the admin role vanishes at the new epoch (silent suppression).
5364        let (bed, owner, member) = TestBed::new();
5365        let attacker = Keys::generate();
5366        bed.swap_to(&owner);
5367        let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
5368        let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
5369        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
5370        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
5371        // Owner folds → the authorized role/grant heads are floored.
5372        let session = SessionGuard::capture();
5373        follow_control(&bed.relay, &community, &session).await.unwrap();
5374        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
5375
5376        // The attacker (a non-owner) forges v2 of the admin role, chaining onto v1.
5377        publish_role(&bed.relay, &community, &attacker, &Role { role_id: rid.clone(), name: "pwn".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 }, 2).await;
5378
5379        // Owner refounds (keeping everyone).
5380        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
5381        assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
5382
5383        // Post-refound, the admin role SURVIVES (the authorized floor head was carried).
5384        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
5385        assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
5386    }
5387
5388    #[tokio::test]
5389    async fn memberlist_survives_a_refounding_via_the_snapshot() {
5390        // A silent survivor (didn't re-post at the new epoch) must stay in the memberlist
5391        // after a refounding — the owner's 3312 snapshot re-seeds them (CORD-02 §5).
5392        let (bed, owner, member) = TestBed::new();
5393        bed.swap_to(&owner);
5394        let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
5395
5396        // Member joins (a Guestbook Join at epoch 0).
5397        let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
5398        bed.swap_to(&member);
5399        accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
5400        bed.swap_to(&owner);
5401        assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
5402
5403        // Owner refounds keeping everyone (removed = []); survivors are snapshotted to epoch 1.
5404        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
5405        assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
5406
5407        // The member is STILL a member at epoch 1 purely via the snapshot (never re-posted).
5408        let members = memberlist(&bed.relay, &refounded).await.unwrap();
5409        assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
5410        assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
5411    }
5412
5413    #[tokio::test]
5414    async fn e2e_two_accounts_channels_converse_moderate() {
5415        use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
5416        use nostr_sdk::prelude::ToBech32;
5417        let (bed, a, b) = TestBed::new();
5418        let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
5419        let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
5420        println!("\n===== Concord v2 deep e2e =====");
5421        println!("[acct] A (owner)  = {a_npub}");
5422        println!("[acct] B (member) = {b_npub}");
5423
5424        // ── A creates the community + a PRIVATE channel + two extra PUBLIC channels ──
5425        bed.swap_to(&a);
5426        let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
5427        let general = community.channels[0].id;
5428        println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
5429
5430        // A PRIVATE channel via the REAL create path: an independent key minted at
5431        // channel-epoch 1, delivered over the rekey plane (A is the only member yet),
5432        // then announced (vsk 2) — later carried to B in the join bundle.
5433        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
5434        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5435        let priv_ch = community.channel(&priv_id).unwrap();
5436        assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
5437        println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
5438
5439        // Two more PUBLIC channels via the real create path.
5440        let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
5441        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5442        let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
5443        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5444        println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
5445        assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
5446
5447        // A talks in a few channels.
5448        let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
5449        send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
5450        send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
5451        println!("[msg] A posted in #general / #announcements / #mods");
5452
5453        // ── A grants B admin, mints a public link, B joins from the bundle ──
5454        let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
5455        publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
5456        publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
5457        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
5458        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
5459        println!("[invite] granted B @admin · minted link {}", link.url);
5460
5461        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.keys.public_key()), None, None)).unwrap();
5462        bed.swap_to(&b);
5463        let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5464        println!("[join] B joined; sees {} channels", b_view.channels.len());
5465        assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
5466        assert!(b_view.channels.iter().any(|c| c.id.0 == priv_id.0 && c.private && c.key.is_some()), "B holds the private channel key");
5467        assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
5468        assert!(texts_in(&bed.relay, &b_view, &priv_id).await.contains(&"A: mods-only channel".to_string()), "B reads the PRIVATE channel with the bundle key");
5469        // B folds the control plane (persisting the roster) — the live worker does
5470        // this right after any join; B's admin standing gates B's channel ops below.
5471        let session_b = SessionGuard::capture();
5472        if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
5473            b_view = fresh;
5474        }
5475        println!("[follow] B folded control (roster persisted: B is @admin)");
5476
5477        // ── Conversation both ways + persistence (get_messages-level) ──
5478        send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
5479        send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
5480        println!("[msg] B replied in #general + #mods");
5481        // Persist B's own #general view into the shared store (what sync/live ingest does)
5482        // and confirm it reads back via STATE — get_messages parity.
5483        let my_pk = b.keys.public_key();
5484        let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
5485        for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
5486            let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
5487            if let Some(o) = outcome { persist_chat(&gh, &o).await; }
5488        }
5489        assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
5490        println!("[persist] #general history persisted into the shared events store");
5491
5492        // B (admin) reacts to + the author edits/deletes — the chat-op surface.
5493        send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
5494        bed.swap_to(&a);
5495        let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
5496        send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
5497        let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
5498        send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
5499        println!("[ops] reaction + edit + delete round-tripped");
5500
5501        // ── B creates a channel as admin, A folds it in ──
5502        bed.swap_to(&b);
5503        let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
5504        println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
5505        bed.swap_to(&a);
5506        let session = SessionGuard::capture();
5507        if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
5508            community = updated;
5509        }
5510        assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
5511        println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
5512
5513        // ── A creates a SECOND private channel while B is already a member: B is a
5514        // recipient of the creation delivery, so B keys up from the rekey plane
5515        // (keyless record → cursor walk → blob) with no bundle involved ──
5516        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
5517        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5518        send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
5519        println!("[channel] +private #vault {} (B is a live member — delivery via rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0));
5520        bed.swap_to(&b);
5521        let session_b2 = SessionGuard::capture();
5522        if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
5523            b_view = fresh;
5524        }
5525        let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
5526        assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
5527        let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
5528        b_view = rf.updated.expect("the rekey walk adopts the creation delivery");
5529        let ch = b_view.channel(&vault).expect("still recorded");
5530        assert!(ch.key.is_some() && ch.epoch == Epoch(1), "B adopted the epoch-1 key from the creation crate");
5531        assert!(
5532            texts_in(&bed.relay, &b_view, &vault).await.contains(&"A: vault is open".to_string()),
5533            "B reads the private history with the ADOPTED key"
5534        );
5535        send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.unwrap();
5536        bed.swap_to(&a);
5537        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5538        assert!(
5539            texts_in(&bed.relay, &community, &vault).await.contains(&"B: in the vault".to_string()),
5540            "A reads B's reply on the natively-created private channel"
5541        );
5542        println!("[private] B adopted #vault via rekey plane; two-way private conversation verified");
5543
5544        // ── Members ──
5545        let members = memberlist(&bed.relay, &community).await.unwrap();
5546        let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
5547        assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
5548        println!("[members] {} members: A + B present", members.len());
5549
5550        // ── Moderate: ban B (banlist + strip + refound), verify severance + survival ──
5551        set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
5552        grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
5553        let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
5554        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
5555        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
5556        assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
5557        assert!(texts_in(&bed.relay, &refounded, &general).await.iter().any(|t| t == "A: welcome to the deep e2e"), "pre-ban history reads across the new epoch");
5558        assert!(
5559            texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
5560            "PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
5561        );
5562        println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
5563        // B concludes it's severed.
5564        bed.swap_to(&b);
5565        let session_b3 = SessionGuard::capture();
5566        assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
5567        println!("[ban] B's rekey-follow: self_removed = true (severed)");
5568
5569        // ── Unban: A lifts the ban ──
5570        bed.swap_to(&a);
5571        set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
5572        let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
5573        assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
5574        println!("[unban] B removed from the banlist (re-invitable)");
5575
5576        // ── Dissolve ──
5577        dissolve_community(&bed.relay, &refounded).await.unwrap();
5578        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
5579        println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
5580    }
5581
5582    /// The same scenario on a REAL relay with TWO throwaway accounts, off by default. It
5583    /// LOGS both nsecs (+ every id) so you can inspect the run and RE-RUN against the same
5584    /// accounts by exporting `VECTOR_E2E_NSEC_A` / `_B`. Set `VECTOR_E2E_LOG=<path>` to also
5585    /// append the transcript to a file, `VECTOR_E2E_RELAY=<url>` to pick the relay.
5586    ///   cargo test -p vector-core -- --ignored --nocapture live_e2e_two_accounts
5587    #[tokio::test]
5588    #[ignore]
5589    async fn live_e2e_two_accounts() {
5590        use crate::community::transport::LiveTransport;
5591        use nostr_sdk::prelude::{ClientBuilder, RelayOptions, ToBech32};
5592
5593        let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
5594        let relays = vec![relay.clone()];
5595        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5596        crate::db::close_database();
5597        crate::db::clear_id_caches();
5598        let tmp = tempfile::tempdir().unwrap();
5599        crate::db::set_app_data_dir(tmp.path().to_path_buf());
5600
5601        // Throwaway (or bring-your-own via env for a re-run against the same accounts).
5602        let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
5603        let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
5604
5605        let log = |line: String| {
5606            println!("{line}");
5607            if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
5608                use std::io::Write;
5609                if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
5610                    let _ = writeln!(f, "{line}");
5611                }
5612            }
5613        };
5614        log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
5615        log(format!("VECTOR_E2E_NSEC_A={}  ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
5616        log(format!("VECTOR_E2E_NSEC_B={}  ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
5617
5618        for k in [&a, &b] {
5619            let npub = k.public_key().to_bech32().unwrap();
5620            std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
5621            crate::db::set_current_account(npub.clone()).unwrap();
5622            crate::db::init_database(&npub).unwrap();
5623        }
5624        // One relay connection: a v2 wrap is pre-signed (ephemeral p-key) and its seal is
5625        // signed by MY_SECRET_KEY, so publishing needs no per-account client signer.
5626        let client = ClientBuilder::new().signer(a.clone()).build();
5627        client.pool().add_relay(relay.as_str(), RelayOptions::default()).await.ok();
5628        client.connect().await;
5629        crate::state::set_nostr_client(client);
5630        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
5631        let become_acct = |k: &Keys| {
5632            let npub = k.public_key().to_bech32().unwrap();
5633            crate::db::set_current_account(npub.clone()).unwrap();
5634            crate::db::init_database(&npub).unwrap();
5635            crate::db::clear_id_caches();
5636            crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
5637            crate::state::set_my_public_key(k.public_key());
5638        };
5639        let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
5640
5641        // A: create + a channel + grant B admin + mint link.
5642        become_acct(&a);
5643        let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
5644        let general = community.channels[0].id;
5645        log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
5646        send_message(&transport, &community, &general, "A: live hello").await.expect("send");
5647        let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
5648        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5649        log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
5650        grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
5651        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
5652        log(format!("[invite] B granted @admin · link {}", link.url));
5653        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.public_key()), None, None)).unwrap();
5654        settle().await;
5655
5656        // B: join + read A's history + reply.
5657        become_acct(&b);
5658        let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
5659        log(format!("[join] B joined; {} channels", b_view.channels.len()));
5660        settle().await;
5661        let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
5662        let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
5663        log(format!("[read] B sees #general: {seen:?}"));
5664        assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
5665        send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
5666
5667        // B posts a NIP-22 kind-1111 THREADED REPLY to A's message (the shape Armada
5668        // sends) directly onto the chat plane — proving the cross-client thread
5669        // RECEIVE path works live, not just in the offline fixture.
5670        let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
5671        let hello_id = hello.event.opened().rumor_id.to_hex();
5672        let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5673        let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
5674        let reply_rumor = chat::build_comment_rumor(bkeys.public_key(), &general, b_view.root_epoch, "B: threaded reply to hello", &hello_id, super::super::kind::MESSAGE, &a.public_key().to_hex(), None, &[], now_ms());
5675        let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
5676        transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
5677        log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
5678        settle().await;
5679
5680        // A reads the thread reply back, rendered inline with A's message as parent.
5681        become_acct(&a);
5682        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5683        let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
5684        let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
5685        if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
5686            assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
5687            assert_eq!(reply_to.as_ref().map(|r| crate::simd::hex::bytes_to_hex_32(&r.id)), Some(hello_id.clone()), "the 1111 renders inline with A's message as parent");
5688        } else {
5689            panic!("the 1111 parsed as a Message");
5690        }
5691        log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
5692        become_acct(&b);
5693        settle().await;
5694
5695        // A: create a PRIVATE channel while B is already a member — B is a recipient
5696        // of the creation delivery, so B keys up from the rekey plane over the real
5697        // relay (no bundle involved), then the two converse on it.
5698        become_acct(&a);
5699        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5700        let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
5701        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5702        send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
5703        log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
5704        settle().await;
5705
5706        become_acct(&b);
5707        let session_b = SessionGuard::capture();
5708        let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
5709        if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
5710            b_view = fresh;
5711        }
5712        if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
5713            b_view = fresh;
5714        }
5715        let vch = b_view.channel(&vault).expect("B folded the vault");
5716        assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
5717        let vseen = texts_in(&transport, &b_view, &vault).await;
5718        log(format!("[read] B sees #vault: {vseen:?}"));
5719        assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
5720        send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
5721        settle().await;
5722
5723        become_acct(&a);
5724        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5725        assert!(
5726            texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
5727            "A reads B's private reply"
5728        );
5729        log("[private] two-way #vault conversation over the live relay".to_string());
5730
5731        // A: ban B (three-removal) + dissolve.
5732        set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
5733        grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
5734        let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
5735        log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
5736        settle().await;
5737        dissolve_community(&transport, &refounded).await.expect("dissolve");
5738        log("[dissolve] community sealed".to_string());
5739        log("===== LIVE e2e PASS =====".to_string());
5740    }
5741
5742    #[tokio::test]
5743    async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
5744        // The tombstone rides its own public plane, watched live — an OFFLINE
5745        // member's catch-up must fetch it too, or they follow (and post into) a
5746        // grave forever.
5747        let (bed, owner, member) = TestBed::new();
5748        bed.swap_to(&owner);
5749        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
5750        let general = community.channels[0].id;
5751        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
5752
5753        bed.swap_to(&member);
5754        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
5755        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
5756
5757        // The owner dissolves while the member sleeps.
5758        bed.swap_to(&owner);
5759        dissolve_community(&bed.relay, &community).await.unwrap();
5760
5761        // The member's catch-up learns of the death, seals, and refuses to post.
5762        bed.swap_to(&member);
5763        let session = SessionGuard::capture();
5764        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
5765        assert!(follow.dissolved, "the catch-up surfaces the tombstone");
5766        assert!(!follow.self_removed && follow.updated.is_none());
5767        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
5768        assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
5769        let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
5770        assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
5771        // Subsequent follows take the local fast path — still dissolved, no churn.
5772        let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
5773        assert!(again.dissolved && again.updated.is_none());
5774    }
5775
5776    #[tokio::test]
5777    async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
5778        // Scale stress: MANY private channels, each rotated on every Refounding.
5779        // A member offline across two refoundings must converge on all of them
5780        // (the per-channel rotation fan in refound + the follow's channel×root×step
5781        // loops stay bounded) with every channel's history readable.
5782        const PRIV_CHANNELS: usize = 6;
5783        let (bed, owner, member) = TestBed::new();
5784        bed.swap_to(&owner);
5785        let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
5786        let mut priv_ids = Vec::new();
5787        for i in 0..PRIV_CHANNELS {
5788            let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
5789            community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5790            send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
5791            priv_ids.push(id);
5792        }
5793        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
5794
5795        // Member joins at epoch 0 with all channel keys, then goes offline.
5796        bed.swap_to(&member);
5797        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5798        assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
5799
5800        // Two refoundings (each rotates the base + every private channel).
5801        bed.swap_to(&owner);
5802        for epoch in 1..=2u64 {
5803            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
5804            assert_eq!(community.root_epoch, Epoch(epoch));
5805            for id in &priv_ids {
5806                send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
5807            }
5808        }
5809
5810        // Member returns: bounded follow to quiescence.
5811        bed.swap_to(&member);
5812        let session = SessionGuard::capture();
5813        let mut passes = 0;
5814        loop {
5815            passes += 1;
5816            assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
5817            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5818            let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
5819            assert!(!rk.self_removed);
5820            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5821            let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
5822            if rk.updated.is_none() && ctl.is_none() {
5823                break;
5824            }
5825        }
5826        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5827        assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
5828        // Every private channel converged to the owner's current key + reads all epochs.
5829        for id in &priv_ids {
5830            let mine = caught_up.channel(id).expect("channel survived");
5831            let theirs = community.channel(id).unwrap();
5832            assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
5833            assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
5834            let texts = texts_in(&bed.relay, &caught_up, id).await;
5835            let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
5836            assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
5837            for epoch in 1..=2u64 {
5838                assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
5839            }
5840        }
5841    }
5842
5843    #[tokio::test]
5844    async fn an_offline_member_catches_up_across_three_refoundings() {
5845        // The deep offline-online scenario: a member sleeps through THREE
5846        // Refoundings, per-refound private-channel rotations, a mid-life private
5847        // channel CREATED while they slept, a public channel, a rename, and a
5848        // ban — then returns and converges by follow alone (no rejoin).
5849        use nostr_sdk::prelude::ToBech32;
5850        let (bed, owner, member) = TestBed::new();
5851        bed.swap_to(&owner);
5852        let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
5853        let general = community.channels[0].id;
5854        let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
5855        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5856        send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
5857        send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
5858        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
5859
5860        // Member joins at epoch 0, then goes OFFLINE.
5861        bed.swap_to(&member);
5862        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5863        assert_eq!(member_view.root_epoch, Epoch(0));
5864
5865        // While they sleep, the owner reshapes everything across three epochs.
5866        bed.swap_to(&owner);
5867        let stranger = Keys::generate();
5868        for epoch in 1..=3u64 {
5869            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
5870            assert_eq!(community.root_epoch, Epoch(epoch));
5871            send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
5872            send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
5873        }
5874        let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
5875        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5876        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
5877        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5878        send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
5879        set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
5880        let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
5881        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
5882
5883        // The member RETURNS: rekey+control follow to quiescence (the worker's
5884        // loop, driven explicitly). Bounded — convergence must be fast.
5885        bed.swap_to(&member);
5886        let session = SessionGuard::capture();
5887        let mut passes = 0;
5888        loop {
5889            passes += 1;
5890            assert!(passes <= 6, "catch-up must converge, not churn");
5891            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5892            let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
5893            assert!(!rekeyed.self_removed, "the member was never removed");
5894            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5895            let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
5896            if rekeyed.updated.is_none() && controlled.is_none() {
5897                break;
5898            }
5899        }
5900        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5901
5902        // Base + name converged.
5903        assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
5904        assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
5905        assert_eq!(caught_up.name, "Sleeper Reborn");
5906        // Channels: renamed set incl. the mid-sleep public + private ones.
5907        assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
5908        let m = caught_up.channel(&mods).expect("mods survived");
5909        let owner_mods = community.channel(&mods).unwrap();
5910        assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
5911        assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
5912        let v = caught_up.channel(&vault).expect("vault folded in");
5913        assert_eq!(v.key, community.channel(&vault).unwrap().key, "adopted the mid-sleep private channel's key");
5914        // Banlist survived the compactions.
5915        let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
5916        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
5917        assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
5918        // History reads across EVERY epoch (public via base-root archive, private
5919        // via the per-channel archive built during the walk).
5920        let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
5921        for epoch in 0..=3u64 {
5922            let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
5923            assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
5924        }
5925        let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
5926        for epoch in 0..=3u64 {
5927            let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
5928            assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
5929        }
5930        assert!(texts_in(&bed.relay, &caught_up, &vault).await.contains(&"epoch3: vault opened".to_string()));
5931        // And the member can still speak.
5932        send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
5933        bed.swap_to(&owner);
5934        assert!(
5935            texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
5936            "the caught-up member converses at the new epoch ({})",
5937            member.keys.public_key().to_bech32().unwrap()
5938        );
5939    }
5940
5941    /// Seal `n` messages onto a community's #general, one per second starting at
5942    /// `base_secs` (distinct wrap seconds so relay-side `until` paging engages).
5943    async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
5944        let general = community.channels[0].id;
5945        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
5946        for i in 0..n {
5947            let at = base_secs + i as u64;
5948            let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
5949            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
5950            relay.publish(&wrap, &community.relays).await.unwrap();
5951        }
5952    }
5953
5954    #[tokio::test]
5955    async fn the_history_walk_pages_past_a_multi_page_burst() {
5956        // A bot offline through 120 messages must catch ALL of them, not the
5957        // newest page — the v1 sync-gap class, closed by until-paging.
5958        let (_tmp, _guard, owner) = init_test_db();
5959        let relay = MemoryRelay::new();
5960        let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
5961        let general = community.channels[0].id;
5962        flood_general(&relay, &community, &owner, 120, 10_000).await;
5963
5964        let all = fetch_channel_history(&relay, &community, &general, 50, 8, |_| true).await.unwrap();
5965        assert_eq!(all.len(), 120, "the walk pages the whole burst");
5966        // Oldest→newest, no duplicates.
5967        let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
5968        assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
5969        assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
5970        let unique: std::collections::HashSet<&String> = contents.iter().collect();
5971        assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
5972
5973        // The single-page fetch stays a single page.
5974        let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
5975        assert_eq!(one.len(), 50, "fetch_channel is one newest page");
5976        assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
5977    }
5978
5979    #[tokio::test]
5980    async fn the_history_walk_stops_when_the_caller_is_caught_up() {
5981        let (_tmp, _guard, owner) = init_test_db();
5982        let relay = MemoryRelay::new();
5983        let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
5984        let general = community.channels[0].id;
5985        flood_general(&relay, &community, &owner, 120, 10_000).await;
5986
5987        // The caller says "I hold everything" after the first page — no deeper fetch.
5988        let mut pages = 0usize;
5989        let got = fetch_channel_history(&relay, &community, &general, 50, 8, |_| {
5990            pages += 1;
5991            false
5992        })
5993        .await
5994        .unwrap();
5995        assert_eq!(pages, 1, "the early stop is consulted once");
5996        assert_eq!(got.len(), 50, "only the newest page is fetched");
5997        assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
5998    }
5999
6000    #[tokio::test]
6001    async fn a_same_second_history_wall_terminates_instead_of_looping() {
6002        // 60 messages in ONE second with a 25-wrap page: a second-granular
6003        // `until` can never page past the wall — the walk must step over it
6004        // (bounded loss, logged) rather than spin.
6005        let (_tmp, _guard, owner) = init_test_db();
6006        let relay = MemoryRelay::new();
6007        let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
6008        let general = community.channels[0].id;
6009        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
6010        for i in 0..60usize {
6011            let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
6012            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
6013            relay.publish(&wrap, &community.relays).await.unwrap();
6014        }
6015        let got = fetch_channel_history(&relay, &community, &general, 25, 8, |_| true).await.unwrap();
6016        assert!(got.len() >= 25, "at least the relay page is read");
6017        assert!(got.len() <= 60, "sane bound");
6018        // Termination is the assertion: reaching here means the wall didn't loop.
6019    }
6020
6021    #[tokio::test]
6022    async fn a_grant_revoke_survives_a_withholding_relay() {
6023        // Floor persistence on the delegation plane: after the owner revokes an admin,
6024        // a relay serving only the OLD (still owner-signed) grant can't resurrect it.
6025        let (_tmp, _guard, owner) = init_test_db();
6026        let relay = MemoryRelay::new();
6027        let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
6028        let admin = Keys::generate();
6029        let rid = "d4".repeat(32);
6030        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
6031        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
6032        let session = SessionGuard::capture();
6033        follow_control(&relay, &community, &session).await.unwrap(); // seed floors incl. the grant at v1
6034        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke → grant floor v2
6035        follow_control(&relay, &community, &session).await.unwrap();
6036
6037        // A stale relay serves only the grant prefix (v1, the live grant).
6038        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
6039        let mut stale = community.clone();
6040        stale.relays = vec!["wss://stale".into()];
6041        let floors = load_floors(&community);
6042        let editions = fetch_control(&relay, &stale).await;
6043        let authority = fold_authority(&stale, &editions, &floors);
6044        assert!(
6045            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
6046            "the persisted grant floor refuses the rolled-back (re-granted) view"
6047        );
6048    }
6049
6050    /// Load the current-epoch floors for a community (test mirror of follow_control).
6051    fn load_floors(community: &CommunityV2) -> Floors {
6052        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6053        crate::db::community::get_all_edition_heads_full(&cid_hex)
6054            .unwrap_or_default()
6055            .into_iter()
6056            .filter(|(_, f)| f.0 == community.root_epoch.0)
6057            .map(|(e, f)| (e, (f.1, f.2, f.3)))
6058            .collect()
6059    }
6060
6061    /// Fetch + open every control edition at a community's control plane (test helper).
6062    async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
6063        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6064        let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
6065        relay
6066            .fetch(&q, &community.relays)
6067            .await
6068            .unwrap_or_default()
6069            .iter()
6070            .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
6071            .collect()
6072    }
6073
6074    #[tokio::test]
6075    async fn follow_control_is_a_noop_on_a_freshly_created_community() {
6076        let (_tmp, _guard, _owner) = init_test_db();
6077        let relay = MemoryRelay::new();
6078        let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
6079        let session = SessionGuard::capture();
6080        // Only the genesis editions exist; folding them reproduces the held view.
6081        assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
6082    }
6083
6084    #[tokio::test]
6085    async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
6086        let (_tmp, _guard, owner) = init_test_db();
6087        let relay = MemoryRelay::new();
6088        let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
6089        let new_id = ChannelId([0x5a; 32]);
6090        publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
6091
6092        let session = SessionGuard::capture();
6093        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
6094        assert_eq!(updated.channels.len(), 2);
6095        let added = updated.channel(&new_id).expect("the new channel folded in");
6096        assert_eq!(added.name, "announcements");
6097        assert!(!added.private);
6098        assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
6099
6100        // The new channel is now in the realtime author-set (it would be subscribed).
6101        let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
6102        let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
6103        assert!(authors.contains(&addr), "the added channel joins the live subscription");
6104
6105        // Persisted: a reload sees it too.
6106        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6107        assert!(reloaded.channel(&new_id).is_some());
6108    }
6109
6110    #[tokio::test]
6111    async fn follow_control_renames_the_community_and_an_existing_channel() {
6112        let (_tmp, _guard, owner) = init_test_db();
6113        let relay = MemoryRelay::new();
6114        let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
6115        let general = community.channels[0].id;
6116        // A v2 metadata edition renames the community; a v2 channel edition renames #general.
6117        publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
6118        publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
6119
6120        let session = SessionGuard::capture();
6121        let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
6122        assert_eq!(updated.name, "New Name");
6123        assert_eq!(updated.channel(&general).unwrap().name, "lobby");
6124        assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
6125    }
6126
6127    #[tokio::test]
6128    async fn follow_control_deletes_a_channel() {
6129        let (_tmp, _guard, owner) = init_test_db();
6130        let relay = MemoryRelay::new();
6131        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
6132        let extra = ChannelId([0x77; 32]);
6133        let session = SessionGuard::capture();
6134
6135        // The channel is first added and folded into the held view.
6136        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
6137        let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
6138        assert!(with_extra.channel(&extra).is_some());
6139
6140        // Then it's tombstoned — the delete (higher version) folds the held one back out.
6141        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
6142        let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
6143        assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
6144        assert_eq!(updated.channels.len(), 1, "only #general remains");
6145    }
6146
6147    /// Re-inject only the OLD prefix (every edition at/below `max_version`) of a
6148    /// community's control plane onto a second relay URL — the withholding-relay
6149    /// simulation: everything it serves is genuinely owner-signed, just stale.
6150    async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
6151        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6152        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
6153        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
6154        for w in &wraps {
6155            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
6156                if ed.version <= max_version {
6157                    relay.inject(w, &[stale_relay.to_string()]);
6158                }
6159            }
6160        }
6161    }
6162
6163    #[tokio::test]
6164    async fn a_withholding_relay_cannot_roll_back_a_rename() {
6165        // W2 persisted floor: after adopting the owner's v2 rename, a relay serving
6166        // only the (owner-signed) v1 genesis must not revert the held name.
6167        let (_tmp, _guard, owner) = init_test_db();
6168        let relay = MemoryRelay::new();
6169        let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
6170        publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
6171
6172        let session = SessionGuard::capture();
6173        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
6174        assert_eq!(updated.name, "Renamed");
6175
6176        // The stale relay holds only the genesis prefix; point the follow at it.
6177        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
6178        let mut stale_view = updated.clone();
6179        stale_view.relays = vec!["wss://stale".into()];
6180        assert!(
6181            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
6182            "a stale-only relay must not change the held view"
6183        );
6184        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6185        assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
6186    }
6187
6188    #[tokio::test]
6189    async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
6190        let (_tmp, _guard, owner) = init_test_db();
6191        let relay = MemoryRelay::new();
6192        let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
6193        let extra = ChannelId([0x44; 32]);
6194        let session = SessionGuard::capture();
6195
6196        // A same-content metadata edit: no visible change (None), but the floor must
6197        // still advance to v2 (so the genesis metadata can't re-present below).
6198        publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
6199        assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
6200
6201        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
6202        let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
6203        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
6204        let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
6205        assert!(pruned.channel(&extra).is_none());
6206
6207        // The stale relay serves the add (v1) but withholds the delete (v2).
6208        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
6209        let mut stale_view = pruned.clone();
6210        stale_view.relays = vec!["wss://stale".into()];
6211        assert!(
6212            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
6213            "the withheld delete must not resurrect the channel"
6214        );
6215        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6216        assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
6217    }
6218
6219    #[tokio::test]
6220    async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
6221        // The Armada-convergence carve-out: a Refounding compacts the chain and
6222        // re-wraps a detached head at the NEW epoch's control plane. The old epoch's
6223        // floor must not block it — epoch-filtering makes the entity bootstrap.
6224        let (_tmp, _guard, owner) = init_test_db();
6225        let relay = MemoryRelay::new();
6226        let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
6227        let session = SessionGuard::capture();
6228        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
6229        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
6230        assert_eq!(updated.name, "Edited");
6231
6232        // Refounding lands (epoch bump saved by the rekey path); the compacted head
6233        // arrives DETACHED (high version, no prev) on the new epoch's plane.
6234        let mut refounded = updated.clone();
6235        refounded.root_epoch = crate::community::Epoch(1);
6236        crate::db::community::save_community_v2(&refounded).unwrap();
6237        publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
6238
6239        let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
6240        assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
6241        // The persisted floor is stamped with the epoch the FOLD ran under.
6242        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6243        let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
6244        assert!(
6245            heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
6246            "the adopted head carries the fold's epoch + version"
6247        );
6248    }
6249
6250    #[tokio::test]
6251    async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
6252        // Two owner-signed editions at the SAME version (publish retry / two owner
6253        // devices): every client must land on the lower-inner-id winner. A hash-strict
6254        // floor would wedge here forever while Armada converges — the floor must
6255        // CONVERGE instead (the v1 decide() rule).
6256        let (_tmp, _guard, owner) = init_test_db();
6257        let relay = MemoryRelay::new();
6258        let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
6259        let session = SessionGuard::capture();
6260        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6261        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
6262
6263        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
6264        let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
6265        assert_eq!(ours.name, "Ours");
6266
6267        // Our committed v2 edition's tiebreak id.
6268        let our_inner = {
6269            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
6270            let wraps = relay.fetch(&q, &community.relays).await.unwrap();
6271            wraps
6272                .iter()
6273                .find_map(|w| {
6274                    control::open_control_edition(w, &group)
6275                        .ok()
6276                        .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
6277                        .map(|(ed, _)| ed.inner_id)
6278                })
6279                .unwrap()
6280        };
6281
6282        // Craft the concurrent fork so it WINS the deterministic tiebreak (vary the
6283        // authored timestamp until its inner id is lower).
6284        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
6285        let content = serde_json::to_string(&meta).unwrap();
6286        let mut ts = 2_000u64;
6287        let fork_wrap = loop {
6288            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
6289            let inner = rumor.id.unwrap().to_bytes();
6290            if inner < our_inner {
6291                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
6292            }
6293            ts += 1;
6294        };
6295        relay.publish(&fork_wrap, &community.relays).await.unwrap();
6296
6297        let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
6298        assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
6299        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6300        let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
6301        assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
6302    }
6303
6304    #[tokio::test]
6305    async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
6306        // v2 chains to the floor; v4 arrives but its v3 link is withheld. The
6307        // chain-verified prefix (v2) applies NOW — refuse-downgrade holds for it —
6308        // while the detached v4 waits. When v3 lands, the chain heals to v4.
6309        let (_tmp, _guard, owner) = init_test_db();
6310        let relay = MemoryRelay::new();
6311        let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
6312        let session = SessionGuard::capture();
6313        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6314
6315        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
6316        let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
6317
6318        // Craft v3 (held back) and v4 (published, chained to the withheld v3).
6319        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
6320        let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
6321        let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
6322        let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
6323        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
6324        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
6325        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
6326        relay.publish(&w4, &community.relays).await.unwrap();
6327
6328        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
6329        assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
6330
6331        relay.publish(&w3, &community.relays).await.unwrap();
6332        let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
6333        assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
6334    }
6335
6336    #[tokio::test]
6337    async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
6338        // The held floor is v2; the owner publishes v3, then a flood of foreign junk
6339        // wraps fills the newest window, then v4. Page 1 sees only v4 (detached →
6340        // gapped); paging older must recover v3 (and the floor link) and heal to v4.
6341        let (_tmp, _guard, owner) = init_test_db();
6342        let relay = MemoryRelay::new();
6343        let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
6344        let session = SessionGuard::capture();
6345        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6346
6347        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
6348        let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
6349        publish_community_meta(&relay, &base, &owner, "Three", 3).await; // ts 1_000 (old)
6350        let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
6351
6352        // Rogue flood occupying the newest window (sealed to the control plane, but
6353        // non-owner — the authority gate drops them; they only crowd the page).
6354        let rogue = Keys::generate();
6355        for i in 0..(FOLLOW_PAGE as u64 - 1) {
6356            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
6357            let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
6358            relay.publish(&w, &community.relays).await.unwrap();
6359        }
6360        // v4 chained to the real v3 (crafted directly: the flood also blinds the
6361        // helper's own newest-window head lookup), timestamped newest of all.
6362        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
6363        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
6364        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
6365        relay.publish(&w4, &community.relays).await.unwrap();
6366
6367        let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
6368        assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
6369    }
6370
6371    #[tokio::test]
6372    async fn a_follow_after_delete_does_not_resurrect_the_community() {
6373        // A leave/delete racing an in-flight follow: the follow must not re-insert
6374        // the community row or floor rows past delete_community's wipe.
6375        let (_tmp, _guard, owner) = init_test_db();
6376        let relay = MemoryRelay::new();
6377        let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
6378        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
6379        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6380        crate::db::community::delete_community(&cid_hex).unwrap();
6381
6382        let session = SessionGuard::capture();
6383        assert!(
6384            follow_control(&relay, &community, &session).await.unwrap().is_none(),
6385            "a follow racing a delete is a no-op"
6386        );
6387        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
6388        assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
6389    }
6390
6391    #[tokio::test]
6392    async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
6393        // The rekey sibling of the follow_control guard: an owner rotation adopted
6394        // mid-race must not upsert the community row back after a leave/delete.
6395        let (_tmp, _guard, owner) = init_test_db();
6396        let relay = MemoryRelay::new();
6397        let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
6398        let new_root = [0xB2; 32];
6399        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
6400        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6401        crate::db::community::delete_community(&cid_hex).unwrap();
6402
6403        let session = SessionGuard::capture();
6404        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6405        assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
6406        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
6407    }
6408
6409    #[tokio::test]
6410    async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
6411        // {v1, v3} on the relays with v2 lost at publish time (a rate-limiting relay
6412        // that still ACKed): the genesis anchors, so an anchored-prefix-first fold
6413        // would take v1 and SEED the joiner's floor there — pinning them below the
6414        // head Armada shows, forever. A joiner (floor 0) must bootstrap v3.
6415        let (bed, owner, member) = TestBed::new();
6416        bed.swap_to(&owner);
6417        let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
6418        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6419        let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
6420
6421        // v2 is crafted but NEVER published; v3 chains to it and is published.
6422        let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
6423        let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
6424        let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
6425        let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
6426        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
6427        let r3 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&ed2.self_hash), &c3, 3_000, None);
6428        let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
6429        bed.relay.publish(&w3, &community.relays).await.unwrap();
6430
6431        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
6432        let bundle_json = serde_json::to_string(&bundle).unwrap();
6433        bed.swap_to(&member);
6434        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
6435        assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
6436        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
6437        let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
6438        assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
6439    }
6440
6441    #[tokio::test]
6442    async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
6443        // The refusal half of fork convergence: a relay withholding OUR committed
6444        // floor edition while serving only a same-version fork with a HIGHER inner
6445        // id must be treated as withholding — held state and floor unchanged.
6446        let (_tmp, _guard, owner) = init_test_db();
6447        let relay = MemoryRelay::new();
6448        let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
6449        let session = SessionGuard::capture();
6450        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6451        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
6452
6453        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
6454        let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
6455        assert_eq!(ours.name, "Ours");
6456        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6457        let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
6458        let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
6459
6460        // Grind the fork to LOSE the tiebreak (higher inner id), then serve it —
6461        // with the genesis but WITHOUT our v2 — from a withholding relay.
6462        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
6463        let content = serde_json::to_string(&meta).unwrap();
6464        let mut ts = 5_000u64;
6465        let fork_wrap = loop {
6466            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
6467            if rumor.id.unwrap().to_bytes() > our_inner {
6468                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
6469            }
6470            ts += 1;
6471        };
6472        inject_stale_prefix(&relay, &community, 1, "wss://stale").await; // genesis only
6473        relay.inject(&fork_wrap, &["wss://stale".to_string()]);
6474        let mut stale_view = ours.clone();
6475        stale_view.relays = vec!["wss://stale".into()];
6476
6477        assert!(
6478            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
6479            "a losing fork served without our floor edition changes nothing"
6480        );
6481        let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
6482        assert_eq!(held_after, held_before, "the floor row is untouched");
6483        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6484        assert_eq!(held.name, "Ours", "the held state is untouched");
6485    }
6486
6487    #[tokio::test]
6488    async fn follow_control_ignores_a_non_owner_edition() {
6489        // A member holds the community_root, so they CAN seal a control edition —
6490        // but they aren't the owner, so the authority gate drops it (first cut:
6491        // owner-only). The rogue channel must never appear.
6492        let (_tmp, _guard, _owner) = init_test_db();
6493        let relay = MemoryRelay::new();
6494        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
6495        let rogue = Keys::generate();
6496        let rogue_id = ChannelId([0x99; 32]);
6497        publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
6498
6499        let session = SessionGuard::capture();
6500        assert!(
6501            follow_control(&relay, &community, &session).await.unwrap().is_none(),
6502            "a non-owner control edition is not folded"
6503        );
6504    }
6505
6506    #[tokio::test]
6507    async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
6508        // A Private channel's key rides the rekey plane, not the control edition —
6509        // control-follow records it KEYLESS (epoch 0, the rekey-scan cursor), and
6510        // every read/send path refuses it until the key lands (never the root plane).
6511        let (_tmp, _guard, owner) = init_test_db();
6512        let relay = MemoryRelay::new();
6513        let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
6514        let priv_id = ChannelId([0x33; 32]);
6515        publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
6516
6517        let session = SessionGuard::capture();
6518        let updated = follow_control(&relay, &community, &session)
6519            .await
6520            .unwrap()
6521            .expect("the keyless record is a change");
6522        let ch = updated.channel(&priv_id).expect("the private channel is recorded");
6523        assert!(ch.private && ch.key.is_none(), "recorded keyless");
6524        assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
6525        assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
6526        assert!(
6527            fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
6528            "a keyless fetch returns empty (and never queries the root plane)"
6529        );
6530        assert!(
6531            send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
6532            "a keyless send refuses"
6533        );
6534        // The keyless record round-trips (the stored placeholder never surfaces
6535        // as a real key).
6536        let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
6537        let rch = reloaded.channel(&priv_id).unwrap();
6538        assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
6539        // And a bundle minted while keyless never carries the placeholder.
6540        let bundle = bundle_of(&reloaded, None, None, None);
6541        assert!(
6542            !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
6543            "an ungrantable keyless channel stays out of invite bundles"
6544        );
6545    }
6546
6547    // ── Live rekey-follow ────────────────────────────────────────────────────
6548
6549    /// Publish an owner-grammar base rotation (Refounding) delivering `new_root`
6550    /// to each recipient. `rotator` is the seal signer (owner for a legit rotation,
6551    /// a stranger for the authority test); `prev_key` is the root it claims to
6552    /// extend (mismatch → a fork).
6553    async fn publish_base_rotation(
6554        relay: &MemoryRelay,
6555        community: &CommunityV2,
6556        rotator: &Keys,
6557        recipients: &[PublicKey],
6558        new_root: &[u8; 32],
6559        prev_key: &[u8; 32],
6560    ) {
6561        let new_epoch = Epoch(community.root_epoch.0 + 1);
6562        let prev_epoch = community.root_epoch;
6563        let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
6564        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
6565        let blobs: Vec<_> = recipients
6566            .iter()
6567            .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
6568            .collect();
6569        let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000).unwrap();
6570        for e in &events {
6571            relay.publish(e, &community.relays).await.unwrap();
6572        }
6573    }
6574
6575    /// Attach a Private channel (key + epoch) to a held community and persist it.
6576    fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
6577        community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
6578        crate::db::community::save_community_v2(community).unwrap();
6579    }
6580
6581    #[tokio::test]
6582    async fn follow_rekeys_is_a_noop_without_rotations() {
6583        let (_tmp, _guard, _owner) = init_test_db();
6584        let relay = MemoryRelay::new();
6585        let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
6586        let session = SessionGuard::capture();
6587        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6588        assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
6589    }
6590
6591    #[tokio::test]
6592    async fn follow_rekeys_adopts_an_owner_base_rotation() {
6593        let (_tmp, _guard, owner) = init_test_db();
6594        let relay = MemoryRelay::new();
6595        let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
6596        let new_root = [0xB1; 32];
6597        // Owner rotates the base to epoch 1, delivering the new root to me.
6598        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
6599
6600        let session = SessionGuard::capture();
6601        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
6602        assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
6603        assert_eq!(updated.community_root, new_root, "adopted the fresh root");
6604        // The public channel now reads under the NEW root/epoch (its address moved).
6605        let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
6606        let general = updated.channels[0].id;
6607        let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
6608        assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
6609    }
6610
6611    #[tokio::test]
6612    async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
6613        let (_tmp, _guard, owner) = init_test_db();
6614        let relay = MemoryRelay::new();
6615        let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
6616        let priv_id = ChannelId([0x33; 32]);
6617        add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
6618
6619        // Owner rotates the private channel to epoch 1 with a fresh key, delivered to me.
6620        let new_key = [0x55; 32];
6621        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
6622        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
6623        let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(priv_id), Epoch(1), &new_key).unwrap();
6624        let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000).unwrap();
6625        for e in &events {
6626            relay.publish(e, &community.relays).await.unwrap();
6627        }
6628
6629        let session = SessionGuard::capture();
6630        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
6631        let ch = updated.channel(&priv_id).unwrap();
6632        assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
6633        assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
6634        assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
6635    }
6636
6637    #[tokio::test]
6638    async fn follow_rekeys_ignores_a_non_owner_rotation() {
6639        // A member holds the community_root, so they can derive the rekey group key
6640        // and mint a rotation — but they aren't the owner, so it's not adopted.
6641        let (_tmp, _guard, _owner) = init_test_db();
6642        let relay = MemoryRelay::new();
6643        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
6644        let rogue = Keys::generate();
6645        publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
6646
6647        let session = SessionGuard::capture();
6648        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6649        assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
6650    }
6651
6652    #[tokio::test]
6653    async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
6654        // A rotation whose prevcommit doesn't match the key I hold is a fork, not an
6655        // extension — never adopted (would splice me onto an unrelated chain).
6656        let (_tmp, _guard, owner) = init_test_db();
6657        let relay = MemoryRelay::new();
6658        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
6659        // prev_key ≠ the real community_root → the continuity check reads Fork.
6660        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
6661
6662        let session = SessionGuard::capture();
6663        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6664        assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
6665    }
6666
6667    #[tokio::test]
6668    async fn follow_rekeys_holds_on_an_incomplete_rotation() {
6669        // A 2-chunk rotation with only chunk 1 present can never conclude — not an
6670        // adoption, and crucially NOT a removal (a missing chunk might carry my blob).
6671        let (_tmp, _guard, owner) = init_test_db();
6672        let relay = MemoryRelay::new();
6673        let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
6674        let new_epoch = Epoch(1);
6675        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
6676        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
6677        // Chunk 1 of a declared 2, carrying someone else's blob (not mine).
6678        let other = Keys::generate();
6679        let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
6680        let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000).unwrap();
6681        let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
6682        relay.publish(&wrap, &community.relays).await.unwrap();
6683
6684        let session = SessionGuard::capture();
6685        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6686        assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
6687    }
6688
6689    #[tokio::test]
6690    async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
6691        // Realistic two-actor removal: the owner Refounds the base and delivers the
6692        // new root to a THIRD party, not the member — a complete rotation with no
6693        // blob for the member is a removal.
6694        let (bed, owner, member) = TestBed::new();
6695        bed.swap_to(&owner);
6696        let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
6697        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6698
6699        bed.swap_to(&member);
6700        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6701        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6702
6703        // Owner rotates, delivering only to a stranger (the member is dropped).
6704        bed.swap_to(&owner);
6705        let stranger = Keys::generate();
6706        publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
6707
6708        // The member's follow concludes removal (a complete rotation without their blob).
6709        bed.swap_to(&member);
6710        let session = SessionGuard::capture();
6711        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
6712        assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
6713        assert!(follow.updated.is_none(), "a removed member adopts nothing");
6714    }
6715
6716    #[tokio::test]
6717    async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
6718        // PROTO-B2 regression: a Refounding's channel rekeys ride the PRIOR root
6719        // (CORD-06 §3). A follower who adopted the BASE first (the live window:
6720        // the base crate landed and was walked before the channel crates) must
6721        // still find them — the lookup fans across the archived roots, not just
6722        // the current one.
6723        let (_tmp, _guard, owner) = init_test_db();
6724        let relay = MemoryRelay::new();
6725        let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
6726        let root0 = community.community_root;
6727        let priv_id = ChannelId([0x33; 32]);
6728        let key1 = [0x44; 32];
6729        add_private_channel(&mut community, priv_id, key1, Epoch(1));
6730
6731        // The refounder's channel rekey (1 → 2), sealed + addressed under the PRIOR
6732        // root (root0), delivering the fresh key to me.
6733        let key2 = [0x55; 32];
6734        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6735        let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
6736        let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
6737        for e in rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &prev_commit, &[blob], 2_000).unwrap() {
6738            relay.publish(&e, &community.relays).await.unwrap();
6739        }
6740
6741        // Simulate the base having ALREADY advanced (the stranding order): the head
6742        // moved to a fresh root while root0 sits in the epoch-key archive (where
6743        // genesis put it).
6744        community.community_root = [0xB7; 32];
6745        community.root_epoch = Epoch(1);
6746        crate::db::community::save_community_v2(&community).unwrap();
6747
6748        let session = SessionGuard::capture();
6749        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
6750        let ch = updated.channel(&priv_id).unwrap();
6751        assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
6752        assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
6753    }
6754
6755    #[tokio::test]
6756    async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
6757        // A keyless private channel (announced by vsk-2, key not yet held) has no
6758        // chain, so its epoch is a scan cursor: a complete rotation that excludes
6759        // us advances the cursor (never a removal — we were never in); a later
6760        // rotation that includes us is the entry point.
6761        let (_tmp, _guard, owner) = init_test_db();
6762        let relay = MemoryRelay::new();
6763        let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
6764        let priv_id = ChannelId([0x66; 32]);
6765        community.channels.push(ChannelV2 { id: priv_id, name: "vault".into(), private: true, key: None, epoch: Epoch(0), voice: None, meta_custom: None, meta_extra: Default::default() });
6766        crate::db::community::save_community_v2(&community).unwrap();
6767        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6768        assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
6769
6770        // Epoch 1: the creation delivery went to a stranger only (pre-dates us).
6771        let stranger = Keys::generate();
6772        let key1 = [0x71; 32];
6773        let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
6774        let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
6775        let b1 = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &stranger.public_key(), RekeyScope::Channel(priv_id), Epoch(1), &key1).unwrap();
6776        for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000).unwrap() {
6777            relay.publish(&e, &community.relays).await.unwrap();
6778        }
6779        // Epoch 2: a later rotation includes ME (e.g. a removal-forced re-mint whose
6780        // recipient set is the CURRENT members).
6781        let key2 = [0x72; 32];
6782        let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6783        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
6784        let b2 = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
6785        for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100).unwrap() {
6786            relay.publish(&e, &community.relays).await.unwrap();
6787        }
6788
6789        // ONE follow: the cursor walks 0→1 (excluded, still keyless) and 1→2 (my
6790        // blob — adopt), because each real step re-loops.
6791        let session = SessionGuard::capture();
6792        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
6793        let ch = updated.channel(&priv_id).unwrap();
6794        assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
6795        assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
6796    }
6797
6798    #[tokio::test]
6799    async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
6800        // CORD-06 §Authority: a CHANNEL rekey is honored from the owner or a
6801        // MANAGE_CHANNELS holder under the persisted roster — so an admin-run
6802        // rotation keys members up; a mere keyholder's forgery never does.
6803        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6804        let (_tmp, _guard, _owner) = init_test_db();
6805        let relay = MemoryRelay::new();
6806        let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
6807        let priv_id = ChannelId([0x88; 32]);
6808        let key1 = [0x91; 32];
6809        add_private_channel(&mut community, priv_id, key1, Epoch(1));
6810
6811        // Persist a roster granting `admin` the Admin role (MANAGE_CHANNELS ⊂ ADMIN_ALL).
6812        let admin = Keys::generate();
6813        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6814        let role = Role::admin("aa".repeat(32));
6815        let roster = CommunityRoles {
6816            roles: vec![role.clone()],
6817            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
6818        };
6819        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
6820
6821        // The ADMIN rotates the channel 1 → 2, delivering to me: adopted.
6822        let key2 = [0x92; 32];
6823        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6824        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
6825        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
6826        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
6827        for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000).unwrap() {
6828            relay.publish(&e, &community.relays).await.unwrap();
6829        }
6830        let session = SessionGuard::capture();
6831        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
6832        assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
6833
6834        // A STRANGER (keyholder, no roster standing) rotates 2 → 3: refused.
6835        let rogue = Keys::generate();
6836        let key3 = [0x93; 32];
6837        let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
6838        let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
6839        let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
6840        for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100).unwrap() {
6841            relay.publish(&e, &updated.relays).await.unwrap();
6842        }
6843        let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
6844        assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
6845    }
6846
6847    #[tokio::test]
6848    async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
6849        // CORD-06 §Authority: the Rotator must strictly OUTRANK every removed
6850        // target. An equal-rank bit-holder's complete rotation that skips my blob
6851        // must read Stay (my record survives); the OWNER's reads Removed. Needs a
6852        // two-account bed: the follower must be a NON-owner admin.
6853        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6854        let (bed, owner, member) = TestBed::new();
6855        bed.swap_to(&owner);
6856        let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
6857
6858        // The MEMBER's device: holds the community + the private channel, with a
6859        // persisted roster granting the member AND a peer the same Admin role.
6860        bed.swap_to(&member);
6861        let mut held = community.clone();
6862        let priv_id = ChannelId([0xAB; 32]);
6863        let key1 = [0xA1; 32];
6864        add_private_channel(&mut held, priv_id, key1, Epoch(1));
6865        let peer = Keys::generate();
6866        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6867        let role = Role::admin("bb".repeat(32));
6868        let roster = CommunityRoles {
6869            roles: vec![role.clone()],
6870            grants: vec![
6871                MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
6872                MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
6873            ],
6874        };
6875        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
6876
6877        // The equal-rank PEER rotates 1 → 2 delivering only to themselves.
6878        let key2 = [0xA2; 32];
6879        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6880        let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
6881        let pb = rekey::build_blob_local(peer.secret_key(), &peer.public_key().to_bytes(), &peer.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
6882        for e in rekey::build_rekey_chunks_local(&peer, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[pb], 2_000).unwrap() {
6883            bed.relay.publish(&e, &held.relays).await.unwrap();
6884        }
6885        let session = SessionGuard::capture();
6886        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
6887        assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
6888        let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
6889        assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
6890
6891        // The OWNER's rotation excluding me IS a removal (owner outranks everyone).
6892        let key3 = [0xA3; 32];
6893        let stranger = Keys::generate();
6894        let ob = rekey::build_blob_local(owner.keys.secret_key(), &owner.keys.public_key().to_bytes(), &stranger.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key3).unwrap();
6895        for e in rekey::build_rekey_chunks_local(&owner.keys, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[ob], 2_100).unwrap() {
6896            bed.relay.publish(&e, &held.relays).await.unwrap();
6897        }
6898        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
6899        let updated = follow.updated.expect("the owner's removal folds");
6900        assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
6901    }
6902
6903    #[tokio::test]
6904    async fn converting_a_public_channel_to_private_is_refused() {
6905        // The conversion (CORD-03 §2) is a key rotation this build doesn't mint yet:
6906        // the producer refuses the flag flip, so no reader is left unkeyable.
6907        let (_tmp, _guard, _owner) = init_test_db();
6908        let relay = MemoryRelay::new();
6909        let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
6910        let general = community.channels[0].id;
6911        let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
6912        let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
6913        assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
6914        // A rename of the same public channel still works.
6915        let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
6916        edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
6917    }
6918
6919    /// Publish a 13302 (signed by `me`) carrying a leave tombstone for `cid_hex` at
6920    /// `removed_at` — simulating a sibling device having left that community.
6921    async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
6922        let doc = super::super::list::CommunityList {
6923            entries: vec![],
6924            tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
6925            extra: Default::default(),
6926        };
6927        let event = super::super::list::build_list_event(me, &doc).unwrap();
6928        relay.publish(&event, relays).await.unwrap();
6929    }
6930
6931    #[tokio::test]
6932    async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
6933        // W1 (send side): a sibling device left X (a remote tombstone). Joining a
6934        // DIFFERENT community must not re-add X to the 13302 with added_at=now,
6935        // which would silently undo the leave everywhere.
6936        let (_tmp, _guard, me) = init_test_db();
6937        let relay = MemoryRelay::new();
6938        let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
6939        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6940
6941        // A sibling leaves X: a remote tombstone strictly newer than X's add.
6942        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
6943
6944        // Now join a different community Y → republish(just_joined = Y).
6945        let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
6946        republish_community_list(&relay, Some(y.id())).await.unwrap();
6947
6948        // X must still read as LEFT in the published list; Y must be live.
6949        let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
6950        assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
6951        assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
6952    }
6953
6954    #[tokio::test]
6955    async fn sync_tears_down_a_community_a_sibling_left() {
6956        // W1 (receive side): a community still held locally that the synced 13302
6957        // shows tombstoned-and-not-live is torn down, so a leave propagates.
6958        let (_tmp, _guard, me) = init_test_db();
6959        let relay = MemoryRelay::new();
6960        let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
6961        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6962        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
6963
6964        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
6965        sync_community_list(&relay, &x.relays).await.unwrap();
6966        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
6967    }
6968
6969    #[tokio::test]
6970    async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
6971        // The re-join case must NOT be torn down: a fresh join re-adds live (beating
6972        // the tombstone), so a later sync keeps it.
6973        let (_tmp, _guard, me) = init_test_db();
6974        let relay = MemoryRelay::new();
6975        let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
6976        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6977        // A stale tombstone from a prior leave (OLDER than the current hold's re-add).
6978        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
6979        // Re-record the membership (a re-join) → live entry at now >> 1.
6980        republish_community_list(&relay, Some(x.id())).await.unwrap();
6981        sync_community_list(&relay, &x.relays).await.unwrap();
6982        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
6983    }
6984
6985    #[tokio::test]
6986    async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
6987        // W2: a transient fetch failure during republish must not drive the
6988        // replaceable-event write (which would drop other entries / regress seeds).
6989        let (_tmp, _guard, _me) = init_test_db();
6990        let good = MemoryRelay::new();
6991        let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
6992        assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
6993
6994        // A transport whose fetch always errors: republish must bail, publishing nothing.
6995        struct FetchErrors;
6996        #[async_trait::async_trait]
6997        impl Transport for FetchErrors {
6998            async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6999            async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
7000                panic!("republish must NOT publish when the remote fetch failed");
7001            }
7002            async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
7003                Ok(())
7004            }
7005            async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
7006                Err("relay unreachable".to_string())
7007            }
7008        }
7009        // Returns Ok (best-effort) but must not have published (the panic guards it).
7010        republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
7011    }
7012
7013    #[tokio::test]
7014    async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
7015        // B1 regression: refound_community's recipient set = memberlist. A member
7016        // the owner GRANTED a role to but who never left a (surviving) Guestbook
7017        // Join — a lurking admin, or one whose Join aged out of the window — must
7018        // still be a rekey recipient, or the Refounding SEVERS them. The folded
7019        // roster's granted members are the consensus-complete backstop.
7020        let (_tmp, _guard, owner) = init_test_db();
7021        let relay = MemoryRelay::new();
7022        let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
7023
7024        // A lurker gets an admin grant but publishes NO Guestbook Join and no chat.
7025        let lurker = Keys::generate();
7026        let rid = "b1".repeat(32);
7027        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7028        publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
7029
7030        // memberlist includes the lurker purely via the roster backstop.
7031        let members = memberlist(&relay, &community).await.unwrap();
7032        assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
7033
7034        // A banned grantee whose grant wasn't stripped is NOT re-admitted.
7035        let banned_grantee = Keys::generate();
7036        publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
7037        set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
7038        let members = memberlist(&relay, &community).await.unwrap();
7039        assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
7040        assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
7041
7042        // And the Refounding actually delivers the new root to the lurker.
7043        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
7044        assert_eq!(refounded.root_epoch, Epoch(1));
7045        let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
7046        let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
7047        let rotations = rekey::collect_rotations(&chunks);
7048        let lurker_x = lurker.public_key().to_bytes();
7049        let delivered = rotations.iter().any(|r| {
7050            rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
7051        });
7052        assert!(delivered, "the Refounding delivered the new root to the granted lurker");
7053    }
7054
7055    #[tokio::test]
7056    async fn the_memberlist_pages_past_a_guestbook_flood() {
7057        // The roleless-member half of B1: >500 Guestbook events must not evict an
7058        // honest member's Join from the counted set (an insider can flood throwaway
7059        // Joins to force exactly this). The pager sees them all.
7060        let (_tmp, _guard, owner) = init_test_db();
7061        let relay = MemoryRelay::new();
7062        let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
7063        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
7064
7065        // An honest member's Join (oldest), then 600 throwaway Joins on top.
7066        let honest = Keys::generate();
7067        let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
7068        let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
7069        relay.publish(&w, &community.relays).await.unwrap();
7070        for i in 0..600u64 {
7071            let throwaway = Keys::generate();
7072            let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
7073            let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
7074            relay.publish(&w, &community.relays).await.unwrap();
7075        }
7076
7077        let members = memberlist(&relay, &community).await.unwrap();
7078        assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
7079    }
7080
7081    #[tokio::test]
7082    async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
7083        // An insider floods the next-epoch rekey address (community_root-derived,
7084        // so any member can seal there) with >200 junk 3303s to push the owner's
7085        // genuine rotation out of a single fetch window. The paginated fetch must
7086        // still recover it and adopt.
7087        let (_tmp, _guard, owner) = init_test_db();
7088        let relay = MemoryRelay::new();
7089        let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
7090        let new_root = [0xD9; 32];
7091        let new_epoch = Epoch(1);
7092        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
7093
7094        // The GENUINE owner rotation lands first (oldest).
7095        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
7096
7097        // Then a member floods 260 well-formed-but-unauthorized junk chunks ON TOP
7098        // (newer), burying the genuine one past the 200 newest.
7099        let rogue = Keys::generate();
7100        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
7101        for i in 0..260u64 {
7102            let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
7103            let rumor = rekey::build_rekey_rumor(rogue.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 1, 3_000 + i).unwrap();
7104            let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
7105            relay.publish(&wrap, &community.relays).await.unwrap();
7106        }
7107
7108        let session = SessionGuard::capture();
7109        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
7110        assert_eq!(updated.root_epoch, Epoch(1));
7111        assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
7112    }
7113
7114    #[tokio::test]
7115    async fn a_swap_during_create_private_channel_aborts_without_a_write() {
7116        // create_private_channel straddles a memberlist fetch (seconds long) then
7117        // whole-row-saves. A swap in that window must abort — never mint a channel
7118        // into the swapped-in account, and never leave a half-published key crate
7119        // adopted locally.
7120        let (bed, owner, _member) = TestBed::new();
7121        bed.swap_to(&owner);
7122        let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
7123        let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
7124
7125        // The memberlist fetch inside create bumps the generation mid-flight.
7126        let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
7127        let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
7128        assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
7129        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7130        assert_eq!(after.channels.len(), before, "no channel row was written");
7131        assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
7132    }
7133
7134    #[tokio::test]
7135    async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
7136        // CORD-06 §Failure-and-races: two DISTINCT authorized rotators mint the
7137        // same channel epoch concurrently (reachable — both hold MANAGE_CHANNELS).
7138        // Every follower must converge on the SAME key (the lexicographically
7139        // lowest), so the community never permanently forks. (Retaining the losing
7140        // fork's key for its race-window messages needs a multi-key-per-epoch
7141        // archive — a deferred refinement shared with v1; convergence, the
7142        // security-critical property, is what this pins.)
7143        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
7144        let (_tmp, _guard, owner) = init_test_db();
7145        let relay = MemoryRelay::new();
7146        let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
7147        let priv_id = ChannelId([0xC0; 32]);
7148        let key1 = [0xC1; 32];
7149        add_private_channel(&mut community, priv_id, key1, Epoch(1));
7150
7151        // Two admins (a, b) both hold the Admin role; I hold the channel key.
7152        let (a, b) = (Keys::generate(), Keys::generate());
7153        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7154        let role = Role::admin("ce".repeat(32));
7155        let roster = CommunityRoles {
7156            roles: vec![role.clone()],
7157            grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
7158        };
7159        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
7160
7161        // Both rotate 1 → 2, each delivering their OWN fresh key to me, off the
7162        // same prevcommit — a genuine same-epoch fork.
7163        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
7164        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
7165        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
7166        let key_a = [0x0A; 32];
7167        let key_b = [0xFB; 32]; // higher — a's must win regardless of publish order
7168        for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
7169            let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
7170            for e in rekey::build_rekey_chunks_local(signer, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000).unwrap() {
7171                relay.publish(&e, &community.relays).await.unwrap();
7172            }
7173        }
7174
7175        let session = SessionGuard::capture();
7176        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
7177        let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
7178        assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
7179
7180        // A SECOND follower (fresh, holding the same epoch-1 key) converges identically.
7181        let mut peer = community.clone();
7182        if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
7183            c.key = Some(key1);
7184            c.epoch = Epoch(1);
7185        }
7186        // Re-run the same fold from the peer's identical starting point → same winner.
7187        let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
7188        assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
7189    }
7190
7191    #[tokio::test]
7192    async fn create_private_channel_refuses_a_member_without_manage_channels() {
7193        // The local mirror of the reader's gate: an unauthorized member is refused
7194        // BEFORE any publish (no floor pollution, no orphan key crate).
7195        let (bed, owner, member) = TestBed::new();
7196        bed.swap_to(&owner);
7197        let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
7198        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
7199
7200        bed.swap_to(&member);
7201        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7202        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
7203        let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
7204        assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
7205        let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
7206        assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
7207    }
7208
7209    // ── Audit regressions ────────────────────────────────────────────────────
7210
7211    #[tokio::test]
7212    async fn accept_rejects_a_bundle_with_a_forged_community_root() {
7213        // The eclipse: community_id commits only to (owner, salt) — both semi-public
7214        // — so a forged invite pairs the REAL triple with an attacker root, and every
7215        // plane derives from it. The join-time owner-genesis check must refuse.
7216        let (bed, owner, member) = TestBed::new();
7217        bed.swap_to(&owner);
7218        let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
7219
7220        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
7221        let mut forged = bundle_of(&community, None, None, None);
7222        forged.community_root = fake.clone();
7223        for ch in &mut forged.channels {
7224            ch.key = fake.clone();
7225        }
7226        let attacker = Keys::generate();
7227        let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
7228        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
7229
7230        bed.swap_to(&member);
7231        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7232        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
7233        assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
7234        assert!(
7235            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
7236            "a rejected join persists nothing"
7237        );
7238    }
7239
7240    #[tokio::test]
7241    async fn follow_control_heals_a_bundle_misclassified_public_channel() {
7242        // A bundle can set a PUBLIC channel's grant key to the attacker's, so the
7243        // joiner addresses it at a plane only the attacker reads. The owner's genuine
7244        // public:false edition must override it on follow.
7245        let (_tmp, _guard, _owner) = init_test_db();
7246        let relay = MemoryRelay::new();
7247        let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
7248        let general = community.channels[0].id;
7249        let mut poisoned = community.clone();
7250        poisoned.channels[0].private = true;
7251        poisoned.channels[0].key = Some([0x66; 32]);
7252        crate::db::community::save_community_v2(&poisoned).unwrap();
7253
7254        let session = SessionGuard::capture();
7255        let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
7256        let ch = healed.channel(&general).unwrap();
7257        assert!(!ch.private, "the owner's public declaration overrides the bundle");
7258        assert_eq!(ch.key, None, "a healed public channel derives from the root");
7259    }
7260
7261    #[tokio::test]
7262    async fn a_deleted_channel_does_not_resurrect_on_reload() {
7263        // save_community_v2 must prune orphan channel rows, or a control-follow delete
7264        // reappears (with a stale key) on the next reload.
7265        let (_tmp, _guard, owner) = init_test_db();
7266        let relay = MemoryRelay::new();
7267        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
7268        let extra = ChannelId([0x77; 32]);
7269        let session = SessionGuard::capture();
7270        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
7271        let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
7272        assert!(with_extra.channel(&extra).is_some());
7273        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
7274        let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
7275        assert!(after.channel(&extra).is_none());
7276
7277        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7278        assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
7279        assert_eq!(reloaded.channels.len(), 1);
7280    }
7281
7282    #[tokio::test]
7283    async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
7284        // channel_id is the sole DB primary key, so a bundle/replay reusing another
7285        // community's channel_id must NOT overwrite that row. It's skipped (not an
7286        // error — erroring would wedge all of this community's control persistence).
7287        let (_tmp, _guard, _owner) = init_test_db();
7288        let relay = MemoryRelay::new();
7289        let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
7290        let a_channel = a.channels[0].id;
7291        let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
7292        let b_channel = b.channels[0].id;
7293        // B's set includes a phantom whose id collides with A's channel.
7294        b.channels.push(ChannelV2 { id: a_channel, name: "phantom".into(), private: false, key: None, epoch: b.root_epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
7295
7296        crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
7297        // A's channel row is untouched.
7298        let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
7299        assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
7300        assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
7301        // B keeps its own channel but never acquired a row for the foreign id.
7302        let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
7303        assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
7304        assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
7305    }
7306
7307    /// A single relay that CAPS every query below the page size (modelling a real
7308    /// relay's maxFilterLimit) and honors `until` — so the join-verify walk MUST
7309    /// paginate to reach an old genesis. MemoryRelay can't model this (it unions then
7310    /// truncates the whole set), which is why a MemoryRelay flood test gives false
7311    /// confidence about the production `LiveTransport` behaviour.
7312    struct CappedRelay {
7313        events: Vec<Event>,
7314        cap: usize,
7315    }
7316    #[async_trait::async_trait]
7317    impl Transport for CappedRelay {
7318        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
7319        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
7320            Ok(())
7321        }
7322        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
7323            Ok(())
7324        }
7325        async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
7326            let mut m: Vec<Event> = self
7327                .events
7328                .iter()
7329                .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
7330                .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
7331                .cloned()
7332                .collect();
7333            m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); // newest first
7334            m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
7335            Ok(m)
7336        }
7337    }
7338
7339    #[tokio::test]
7340    async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
7341        // The join-verify DoS mitigation, tested against a relay that caps below PAGE
7342        // (production behaviour MemoryRelay hides): a rogue root-holder buries the
7343        // genesis under junk, and the `until`-walk must page past it. Uses fixed OLD
7344        // timestamps so `until = now` includes everything and the walk is deterministic.
7345        let (_tmp, _guard, owner) = init_test_db();
7346        let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
7347        let g = control::genesis(&owner, meta, 1_000).unwrap();
7348        let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
7349
7350        let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
7351        let rogue = Keys::generate();
7352        let mut events: Vec<Event> = g.wraps.to_vec();
7353        for i in 0..250u64 {
7354            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
7355            let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
7356            events.push(wrap);
7357        }
7358        // Cap 100/query forces the walk across ~3 pages down to the genesis at ts 1000.
7359        let relay = CappedRelay { events, cap: 100 };
7360        let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
7361        assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
7362    }
7363
7364    #[tokio::test]
7365    async fn accept_parked_invite_joins_from_the_stored_bundle() {
7366        // The 3313 receive path: an invite is parked as its bundle JSON, then accepted
7367        // from the stored bundle (re-verifying the owner root over the network).
7368        let (bed, owner, member) = TestBed::new();
7369        bed.swap_to(&owner);
7370        let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
7371        let general = community.channels[0].id;
7372        send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
7373        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7374        let bundle_json = serde_json::to_string(&bundle).unwrap();
7375        let inviter_hex = owner.keys.public_key().to_hex();
7376
7377        bed.swap_to(&member);
7378        let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
7379        assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
7380        assert!(joined.identity.verify());
7381        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
7382        // The join seeded the verified fold as the member's initial floor, so their
7383        // first follow can't roll below the state the join just showed.
7384        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
7385        assert!(
7386            crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
7387            "the joiner's control floor is seeded from the join-time fold"
7388        );
7389
7390        // The Guestbook memberlist now folds both participants.
7391        bed.swap_to(&owner);
7392        let members = memberlist(&bed.relay, &community).await.unwrap();
7393        assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
7394    }
7395
7396    #[tokio::test]
7397    async fn accept_parked_invite_rejects_a_forged_root() {
7398        // A forged-root parked bundle (real identity triple, attacker-chosen root) fails
7399        // accept — the shared accept path re-verifies the owner root, so a parked invite
7400        // gets the same eclipse protection as a live one.
7401        let (_tmp, _guard, _owner) = init_test_db();
7402        let relay = MemoryRelay::new();
7403        let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
7404        let mut forged = bundle_of(&community, None, None, None);
7405        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
7406        forged.community_root = fake.clone();
7407        for ch in &mut forged.channels {
7408            ch.key = fake.clone();
7409        }
7410        let bundle_json = serde_json::to_string(&forged).unwrap();
7411
7412        let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
7413        assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
7414    }
7415
7416    #[test]
7417    fn v2_and_v1_bundles_are_distinguishable_by_parse() {
7418        // The protocol discriminator the facade list/accept relies on: a v2 bundle
7419        // (self-certifying: owner + owner_salt + community_root) parses; a v1-shaped
7420        // one does not, so a parked invite routes to the right accept path.
7421        let owner = Keys::generate();
7422        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
7423        let hex = crate::simd::hex::bytes_to_hex_32;
7424        let v2 = invite::CommunityInvite {
7425            community_id: hex(&identity.community_id.0),
7426            owner: hex(&identity.owner_xonly),
7427            owner_salt: hex(&identity.owner_salt),
7428            community_root: hex(&[0x11; 32]),
7429            root_epoch: 0,
7430            channels: vec![],
7431            relays: vec!["wss://r".into()],
7432            name: "V2".into(),
7433            icon: None,
7434            expires_at: None,
7435            creator_npub: None,
7436            label: None,
7437            extra: Default::default(),
7438        };
7439        let v2_json = serde_json::to_string(&v2).unwrap();
7440        assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
7441        let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
7442        assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
7443    }
7444
7445    #[tokio::test]
7446    async fn verify_rejects_a_cross_community_owner_edition_replay() {
7447        // The eclipse-via-replay: an owner-signed edition from community X (eid == X.id)
7448        // rewrapped onto a FORGED community T's fake control plane must NOT authenticate
7449        // T. T's genesis has eid == T.id, so X's edition — a genuine owner signature but
7450        // a different eid — is not a valid proof of T's root. This is why "any owner
7451        // edition" is unsound and the eid==community_id genesis pin is required.
7452        let (_tmp, _guard, owner) = init_test_db();
7453
7454        // Community X (real), owned by `owner`.
7455        let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
7456        let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
7457        let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
7458
7459        // Forged community T: the real owner triple but an ATTACKER-chosen root.
7460        let t_identity = control::CommunityIdentity::mint(&owner.public_key());
7461        let fake_root = [0xEE; 32];
7462        let t = CommunityV2 {
7463            identity: t_identity,
7464            community_root: fake_root,
7465            root_epoch: Epoch(0),
7466            name: "T".into(),
7467            description: None,
7468            icon: None,
7469            banner: None,
7470            meta_custom: None,
7471            meta_extra: Default::default(),
7472            relays: vec!["wss://r".into()],
7473            channels: vec![],
7474            dissolved: false,
7475            created_at_ms: 0,
7476        };
7477        // Rewrap X's owner-signed genesis onto T's fake control plane (the attacker
7478        // controls the fake root, so they can derive its control group key).
7479        let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
7480        let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
7481        let relay = MemoryRelay::new();
7482        relay.publish(&replayed, &t.relays).await.unwrap();
7483
7484        let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
7485        assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
7486    }
7487
7488    /// LIVE smoke test (network) — ignored by default. Creates a v2 community on a
7489    /// REAL relay via `LiveTransport`, sends a message, fetches it back, and mints
7490    /// a public link. A fresh throwaway identity in an isolated temp data dir, so
7491    /// it never touches real accounts. Run explicitly:
7492    /// ```sh
7493    /// cargo test -p vector-core -- --ignored --nocapture live_smoke
7494    /// ```
7495    #[tokio::test]
7496    #[ignore = "hits a real relay over the network"]
7497    async fn live_smoke_create_send_fetch_on_a_real_relay() {
7498        use crate::community::transport::LiveTransport;
7499        use nostr_sdk::prelude::ToBech32;
7500
7501        let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
7502        let relays = vec![relay.clone()];
7503
7504        // Isolated account + data dir (a fresh throwaway key — never a real account).
7505        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
7506        crate::db::close_database();
7507        crate::db::clear_id_caches();
7508        let tmp = tempfile::tempdir().unwrap();
7509        // Bring your own key (VECTOR_SMOKE_NSEC) to create a community you can log
7510        // into elsewhere; otherwise a fresh throwaway.
7511        let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
7512            Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
7513            Err(_) => Keys::generate(),
7514        };
7515        let npub = keys.public_key().to_bech32().unwrap();
7516        // Off by default (never leak secrets from a committed test); set
7517        // VECTOR_SMOKE_PRINT_NSEC=1 to print the owner nsec for cross-client login.
7518        if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
7519            println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
7520        }
7521        std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
7522        crate::db::set_app_data_dir(tmp.path().to_path_buf());
7523        crate::db::set_current_account(npub.clone()).unwrap();
7524        crate::db::init_database(&npub).unwrap();
7525        crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
7526        crate::state::set_my_public_key(keys.public_key());
7527        println!("[smoke] throwaway identity {npub}");
7528
7529        // A live client (LiveTransport rides the global NOSTR_CLIENT + warms relays).
7530        let client = nostr_sdk::ClientBuilder::new().signer(keys.clone()).build();
7531        client.pool().add_relay(relay.as_str(), nostr_sdk::RelayOptions::default()).await.ok();
7532        client.connect().await;
7533        crate::state::set_nostr_client(client);
7534        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
7535
7536        // Create → send → fetch-back → verify.
7537        let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
7538        let general = community.channels[0].id;
7539        println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
7540
7541        let text = "hello from a Vector Concord v2 live smoke test";
7542        let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
7543        println!("[smoke] sent message {sent_id}");
7544
7545        // Give the relay a moment to store + be ready to serve it.
7546        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
7547
7548        let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
7549        let texts: Vec<String> = page
7550            .iter()
7551            .filter_map(|f| match &f.event {
7552                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
7553                _ => None,
7554            })
7555            .collect();
7556        println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
7557        assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
7558
7559        // Mint a shareable v2 link (the thing a bot hands out).
7560        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
7561        println!("[smoke] invite link: {}", link.url);
7562        println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
7563    }
7564
7565    #[tokio::test]
7566    async fn chat_ops_react_edit_delete_round_trip() {
7567        let (bed, owner, _member) = TestBed::new();
7568        bed.swap_to(&owner);
7569        let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
7570        let general = community.channels[0].id;
7571        let me_hex = owner.keys.public_key().to_hex();
7572
7573        let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
7574        send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
7575            .await
7576            .unwrap();
7577        send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
7578        send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
7579
7580        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7581        let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
7582        let mut saw = (false, false, false);
7583        for f in &page {
7584            match &f.event {
7585                ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
7586                    assert_eq!(emoji, ":fire:");
7587                    assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
7588                    saw.0 = true;
7589                }
7590                ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
7591                    assert_eq!(new_content, "edited");
7592                    saw.1 = true;
7593                }
7594                ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
7595                _ => {}
7596            }
7597        }
7598        assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
7599    }
7600
7601    #[tokio::test]
7602    async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
7603        let (bed, owner, _member) = TestBed::new();
7604        bed.swap_to(&owner);
7605        let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
7606        let general = community.channels[0].id;
7607        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
7608
7609        // A live subscriber sees the 21059 wrap and it opens as Typing…
7610        let mut sub = bed.relay.subscribe(Query {
7611            kinds: vec![stream::KIND_WRAP_EPHEMERAL],
7612            authors: vec![group.pk_hex()],
7613            ..Default::default()
7614        });
7615        send_typing(&bed.relay, &community, &general).await.unwrap();
7616        let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
7617        let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
7618            Ok(ChatEvent::Typing { opened }) => opened,
7619            other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
7620        };
7621
7622        // …while nothing durable is stored (relays never keep the ephemeral tier),
7623        // so channel history stays free of typing noise…
7624        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7625        assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
7626
7627        // …and no scrub key is retained (there is no durable wrap to ever delete).
7628        assert!(
7629            crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
7630            "ephemeral sends must not retain scrub keys"
7631        );
7632    }
7633
7634    #[tokio::test]
7635    async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
7636        let (bed, owner, _member) = TestBed::new();
7637        bed.swap_to(&owner);
7638        let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
7639        let general = community.channels[0].id;
7640        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
7641
7642        let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
7643
7644        // Retained: the row maps the rumor id to the exact published wrap, holds the
7645        // key that SIGNED that wrap (same-author NIP-09), and the relay set.
7646        let (keys, outer_hex, relays) =
7647            crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
7648        assert_eq!(relays, community.relays);
7649        let wrap_query = Query {
7650            kinds: vec![stream::KIND_WRAP],
7651            authors: vec![group.pk_hex()],
7652            ..Default::default()
7653        };
7654        let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
7655        let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
7656        assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
7657
7658        // Reactions ride the same retention (revoke_reaction's relay-nuke layer).
7659        let me_hex = owner.keys.public_key().to_hex();
7660        let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
7661            .await
7662            .unwrap();
7663        assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
7664
7665        // The shared v1 delete path (Layer 1 of delete_community_message / revoke_reaction)
7666        // scrubs the wrap off the relay via the retained key, then consumes the row.
7667        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
7668        assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
7669        let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
7670        assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
7671    }
7672
7673    #[tokio::test]
7674    async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
7675        let (bed, owner, _member) = TestBed::new();
7676        bed.swap_to(&owner);
7677        let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
7678        let general = community.channels[0].id;
7679        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
7680
7681        // Simulate a pre-retention / other-device send: our message on the relay,
7682        // but no local mapping row.
7683        let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
7684        crate::db::community::delete_message_key(&id).unwrap();
7685        assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
7686
7687        // A stranger member's message rides the same channel.
7688        let mkeys = Keys::generate();
7689        let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
7690        let foreign_id = rumor.id.unwrap().to_hex();
7691        let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
7692        bed.relay.publish(&fw, &community.relays).await.unwrap();
7693
7694        // One history open re-derives the mapping for the OWN message…
7695        fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7696        let (keys, _outer, relays) =
7697            crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
7698        assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
7699        assert_eq!(relays, community.relays);
7700
7701        // …and never manufactures one for a foreign author.
7702        assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
7703
7704        // The healed row is a working full delete: the shared path scrubs the wrap.
7705        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
7706        let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7707        assert!(
7708            !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
7709            "healed message scrubbed from the relay"
7710        );
7711    }
7712
7713    #[tokio::test]
7714    async fn send_chat_message_threads_the_reply_and_extra_tags() {
7715        let (bed, owner, _member) = TestBed::new();
7716        bed.swap_to(&owner);
7717        let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
7718        let general = community.channels[0].id;
7719        let me_hex = owner.keys.public_key().to_hex();
7720
7721        let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
7722        let imeta = nostr_sdk::prelude::Tag::custom(
7723            nostr_sdk::prelude::TagKind::custom("imeta"),
7724            ["url https://e/blob".to_string(), "m image/png".to_string()],
7725        );
7726        let child_id = send_chat_message(
7727            &bed.relay, &community, &general, "child",
7728            Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
7729        )
7730        .await
7731        .unwrap();
7732
7733        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7734        let child = page
7735            .iter()
7736            .find_map(|f| match &f.event {
7737                ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
7738                _ => None,
7739            })
7740            .expect("the reply message round-trips");
7741        let reply = child.1.as_ref().expect("the reply reference is carried");
7742        assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
7743        assert_eq!(reply.author, Some(owner.keys.public_key()));
7744        assert!(
7745            child.0.rumor.tags.iter().any(|t| t.kind() == nostr_sdk::prelude::TagKind::custom("imeta")),
7746            "the imeta attachment tag rides the rumor verbatim"
7747        );
7748    }
7749
7750    #[tokio::test]
7751    async fn a_kick_needs_kick_authority_and_removes_the_target() {
7752        let (bed, owner, member) = TestBed::new();
7753        bed.swap_to(&owner);
7754        let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
7755
7756        // The target announces a Join (as an accepted invite would).
7757        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
7758        let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
7759        let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
7760        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
7761        let before = memberlist(&bed.relay, &community).await.unwrap();
7762        assert!(before.contains(&member.keys.public_key()), "the join lands first");
7763
7764        // An unprivileged member's kick of the owner is refused locally…
7765        bed.swap_to(&member);
7766        let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
7767        assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
7768
7769        // …and the owner (supreme, no grant needed) kicks the member out.
7770        bed.swap_to(&owner);
7771        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7772        let after = memberlist(&bed.relay, &community).await.unwrap();
7773        assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
7774        assert!(after.contains(&owner.keys.public_key()), "the owner remains");
7775    }
7776
7777    #[tokio::test]
7778    async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
7779        let (bed, owner, member) = TestBed::new();
7780        bed.swap_to(&owner);
7781        let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
7782        let member_pk = member.keys.public_key();
7783        let member_hex = member_pk.to_hex();
7784        let owner_hex = owner.keys.public_key().to_hex();
7785
7786        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
7787        let view = fetch_authority(&bed.relay, &community).await;
7788        assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
7789        assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
7790
7791        // A second grant (any device) converges on the SAME role entity — and a
7792        // repeat is a no-op, not a version bump.
7793        let second = Keys::generate().public_key();
7794        grant_admin(&bed.relay, &community, &second).await.unwrap();
7795        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
7796        let view = fetch_authority(&bed.relay, &community).await;
7797        assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
7798        assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
7799        let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
7800        assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
7801
7802        // Revoke strips ONLY the admin role and de-authorizes.
7803        revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
7804        let view = fetch_authority(&bed.relay, &community).await;
7805        assert!(!view.roles.is_admin(&member_hex), "revoked");
7806        assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
7807        assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
7808    }
7809
7810    #[tokio::test]
7811    async fn follow_control_persists_the_roster_for_sync_local_reads() {
7812        let (bed, owner, member) = TestBed::new();
7813        bed.swap_to(&owner);
7814        let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
7815        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7816        let member_hex = member.keys.public_key().to_hex();
7817        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7818
7819        // The passive follow folds + persists; the read is then LOCAL (v1 parity).
7820        let session = crate::state::SessionGuard::capture();
7821        follow_control(&bed.relay, &community, &session).await.unwrap();
7822        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7823        assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
7824
7825        // A withholding relay serves nothing — an empty fold raises no gap flag, and
7826        // the stored roster must be RETAINED, never wiped.
7827        let withholding = MemoryRelay::new();
7828        let _ = follow_control(&withholding, &community, &session).await;
7829        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7830        assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
7831
7832        // A real revocation (a NEWER grant edition) does replace it.
7833        revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7834        follow_control(&bed.relay, &community, &session).await.unwrap();
7835        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7836        assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
7837    }
7838
7839    #[tokio::test]
7840    async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
7841        let (bed, owner, member) = TestBed::new();
7842        bed.swap_to(&owner);
7843        let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
7844
7845        bed.swap_to(&member);
7846        let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
7847        assert!(err.contains("owner"), "refused before any publish: {err}");
7848
7849        // The deterministic admin-role entity stays unsquatted — the owner's later
7850        // legitimate mint is version 1 and folds cleanly.
7851        bed.swap_to(&owner);
7852        let view = fetch_authority(&bed.relay, &community).await;
7853        assert!(view.roles.roles.is_empty(), "no role edition landed");
7854        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7855        let view = fetch_authority(&bed.relay, &community).await;
7856        assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
7857    }
7858
7859    #[tokio::test]
7860    async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
7861        let (bed, owner, member) = TestBed::new();
7862        bed.swap_to(&owner);
7863        let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
7864        let member_pk = member.keys.public_key();
7865
7866        // The member already holds a Mod role, granted through the real send path
7867        // (so this device's floors track both entities).
7868        let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
7869        set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
7870        grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
7871
7872        // A relay that withholds the control plane must refuse the merge — a blind
7873        // push would erase the Mod role at a higher version.
7874        let withholding = MemoryRelay::new();
7875        let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
7876        assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
7877
7878        // Against the full relay the merge preserves the Mod role.
7879        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
7880        let view = fetch_authority(&bed.relay, &community).await;
7881        let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
7882        assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
7883        assert!(grant.role_ids.contains(&mod_rid));
7884    }
7885
7886    #[tokio::test]
7887    async fn fetch_authority_reflects_a_granted_admin() {
7888        let (bed, owner, member) = TestBed::new();
7889        bed.swap_to(&owner);
7890        let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
7891        let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
7892        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7893        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
7894
7895        let view = fetch_authority(&bed.relay, &community).await;
7896        let member_hex = member.keys.public_key().to_hex();
7897        assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
7898        assert!(
7899            view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
7900            "an ADMIN_ALL grant carries KICK"
7901        );
7902        assert!(view.banned.is_empty());
7903    }
7904}