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    // Address every held epoch by its Chat-Plane pubkey.
429    let authors: Vec<String> = coords
430        .iter()
431        .map(|(secret, epoch)| channel_group_key(secret, channel_id, *epoch).pk_hex())
432        .collect();
433
434    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
435    let mut seen_rumors = std::collections::HashSet::new();
436    let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
437    let mut until: Option<u64> = None;
438    let mut oldest: Option<u64> = None;
439    for _ in 0..max_pages {
440        let query = Query {
441            kinds: vec![stream::KIND_WRAP],
442            authors: authors.clone(),
443            until,
444            limit: Some(page),
445            ..Default::default()
446        };
447        let wraps = transport.fetch(&query, &community.relays).await?;
448        if wraps.is_empty() {
449            break;
450        }
451        let mut fresh = 0usize;
452        let mut page_events: Vec<FetchedEvent> = Vec::new();
453        for wrap in &wraps {
454            if !seen_wraps.insert(wrap.id) {
455                continue;
456            }
457            fresh += 1;
458            let at = wrap.created_at.as_secs();
459            if oldest.is_none_or(|o| at < o) {
460                oldest = Some(at);
461            }
462            // Select the epoch whose group key authored this wrap (no trial decrypt).
463            for (secret, epoch) in &coords {
464                let group = channel_group_key(secret, channel_id, *epoch);
465                if wrap.pubkey != group.pk() {
466                    continue;
467                }
468                if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
469                    let id = event.opened().rumor_id;
470                    if seen_rumors.insert(id) {
471                        if session.is_valid() {
472                            heal_own_wrap_key(&event, &group, &community.relays);
473                        }
474                        page_events.push(FetchedEvent { event, epoch: *epoch });
475                    }
476                }
477                break;
478            }
479        }
480        if fresh == 0 {
481            if wraps.len() < page {
482                break; // drained — the relay has nothing older.
483            }
484            // A full page of already-seen wraps: a same-second WALL. Step past it;
485            // same-second siblings beyond the relay's cap are unreachable by a
486            // second-granular filter.
487            let Some(o) = oldest else { break };
488            if o == 0 {
489                break;
490            }
491            crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
492            until = Some(o - 1);
493            continue;
494        }
495        let stop = !page_events.is_empty() && !keep_paging(&page_events);
496        out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
497        if stop {
498            break; // the caller holds everything from here back.
499        }
500        until = oldest; // inclusive — wrap-id dedup absorbs the boundary overlap.
501    }
502    out.sort_by_key(|(ms, _)| *ms);
503    Ok(out.into_iter().map(|(_, e)| e).collect())
504}
505
506// ── Invites (CORD-05) ────────────────────────────────────────────────────────
507
508/// Build the §1 invite bundle for this community. Every channel is granted: a
509/// Public channel carries the `community_root` as its "key" (the joiner derives
510/// the real secret from the root), a Private one its own key. The bundle
511/// self-certifies the owner, so the inviter's identity is irrelevant to trust.
512pub fn bundle_of(
513    community: &CommunityV2,
514    creator: Option<PublicKey>,
515    expires_at_ms: Option<u64>,
516    label: Option<String>,
517) -> CommunityInvite {
518    let hex = crate::simd::hex::bytes_to_hex_32;
519    let channels = community
520        .channels
521        .iter()
522        // A KEYLESS private channel can't be granted (we hold no key) — carrying the
523        // root placeholder would make the joiner classify it PUBLIC and address a
524        // private channel at the public plane. It joins their view via control-follow
525        // (keyless) and keys up at the channel's next rotation.
526        .filter(|c| !(c.private && c.key.is_none()))
527        .map(|c| invite::ChannelGrant {
528            id: hex(&c.id.0),
529            key: hex(&c.key.unwrap_or(community.community_root)),
530            epoch: c.epoch.0,
531            name: c.name.clone(),
532        })
533        .collect();
534    CommunityInvite {
535        community_id: hex(&community.identity.community_id.0),
536        owner: hex(&community.identity.owner_xonly),
537        owner_salt: hex(&community.identity.owner_salt),
538        community_root: hex(&community.community_root),
539        root_epoch: community.root_epoch.0,
540        channels,
541        relays: community.relays.clone(),
542        name: community.name.clone(),
543        // Mint-time snapshot so a parked invite renders the real logo before any
544        // fold; the Control Plane stays the authority after joining.
545        icon: community.icon.clone(),
546        expires_at: expires_at_ms,
547        creator_npub: creator.map(|p| p.to_hex()),
548        label,
549        extra: Default::default(),
550    }
551}
552
553/// Gift-wrap a Direct Invite (kind 3313) of this community straight to `recipient`
554/// and publish it to the community relays. `expires_at_ms` (unix ms) optionally
555/// bounds its shelf life; `label` is echoed in the joiner's Guestbook Join. The
556/// bundle hands over the keys; the recipient consents by accepting (nothing joins
557/// on receipt). Returns the wrap.
558pub async fn send_direct_invite<T: Transport + ?Sized>(
559    transport: &T,
560    community: &CommunityV2,
561    recipient: &PublicKey,
562    expires_at_ms: Option<u64>,
563    label: Option<String>,
564) -> Result<Event, String> {
565    let session = SessionGuard::capture();
566    let inviter = local_keys()?;
567    let bundle = bundle_of(community, Some(inviter.public_key()), expires_at_ms, label);
568    let wrap = invite::build_direct_invite(&inviter, recipient, &bundle).map_err(|e| e.to_string())?;
569    if !session.is_valid() {
570        return Err("account changed before sending invite".to_string());
571    }
572    transport.publish(&wrap, &community.relays).await?;
573    Ok(wrap)
574}
575
576/// A minted public link: the shareable URL plus the addressable bundle event to
577/// publish and the link keypair to retain (in the Invite List) for later refresh
578/// or revocation.
579pub struct MintedLink {
580    pub url: String,
581    pub bundle_event: Event,
582    pub link_signer: Keys,
583    pub token: [u8; super::derive::TOKEN_LEN],
584}
585
586/// Mint a public invite link for this community: a fresh token + link keypair, the
587/// bundle encrypted under the token key and published at `(33301, link_signer,
588/// "")`, and the `base/invite/<naddr>#<fragment>` URL. `base` is the deep-link
589/// domain (e.g. `https://vectorapp.io`); the fragment carries the token + bootstrap
590/// relays and never reaches a server.
591pub async fn mint_public_link<T: Transport + ?Sized>(
592    transport: &T,
593    community: &CommunityV2,
594    base: &str,
595    expires_at_ms: Option<u64>,
596    label: Option<String>,
597) -> Result<MintedLink, String> {
598    let session = SessionGuard::capture();
599    let mut token = [0u8; super::derive::TOKEN_LEN];
600    token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
601    let link_signer = Keys::generate();
602    let bundle = bundle_of(community, Some(local_keys()?.public_key()), expires_at_ms, label.clone());
603    let bundle_key = super::derive::invite_bundle_key(&token);
604    let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
605    let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
606
607    if !session.is_valid() {
608        return Err("account changed before minting link".to_string());
609    }
610    transport.publish_durable(&bundle_event, &community.relays).await?;
611    let minted = MintedLink { url, bundle_event, link_signer, token };
612    // Sync the link across the creator's devices (13303) + publish the Registry
613    // (vsk-8) so members see the community is Public. Best-effort — the link works
614    // without the sync.
615    let _ = record_minted_link(transport, community, &minted).await;
616    // Local mirror so `list_public_invites` stays a sync local read (v1 parity);
617    // the 13303 list remains the cross-device record. Re-check the session: the
618    // publishes above straddled awaits, and this write must not land account A's
619    // link (secret token included) in a swapped-in account's DB.
620    if session.is_valid() {
621        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
622        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
623        let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
624    }
625    Ok(minted)
626}
627
628// ── The Invite Registry (vsk 8) + Invite List (13303), CORD-05 §4/§5 ──────────
629
630/// Fetch the creator's own 13303 Invite List from `relays` (newest wins; a
631/// decrypt/parse failure is "no news", never a clobber of the local mirror).
632async fn fetch_invite_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Option<invite::InviteList> {
633    let me = local_keys().ok()?;
634    let query = Query {
635        kinds: vec![super::kind::INVITE_LIST],
636        authors: vec![me.public_key().to_hex()],
637        limit: Some(4),
638        ..Default::default()
639    };
640    let events = transport.fetch(&query, relays).await.ok()?;
641    events
642        .into_iter()
643        .filter_map(|e| invite::parse_invite_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
644        .max_by_key(|(at, _)| *at)
645        .map(|(_, l)| l)
646}
647
648/// The creator's LIVE (non-tombstoned) link-signer pubkeys for one community — the
649/// Registry's content (CORD-05 §5), derived from the stored link secrets.
650fn live_signers_for(list: &invite::InviteList, community_id_hex: &str) -> Vec<PublicKey> {
651    let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
652    list.entries
653        .iter()
654        .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
655        .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
656        .collect()
657}
658
659/// Publish the creator's Registry (vsk-8) edition — their live link signers for this
660/// community — so members fold it into the Public/Private source of truth (a
661/// non-empty aggregate = Public).
662async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
663    let me = local_keys()?;
664    let eid = super::derive::invite_links_locator(community.id(), &me.public_key().to_bytes());
665    let content = invite::build_registry_content(live_signers);
666    publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content, None).await
667}
668
669/// Record a freshly-minted public link across the creator's devices: append it to the
670/// 13303 Invite List and refresh the Registry (CORD-05 §4/§5).
671async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
672    let session = SessionGuard::capture();
673    let me = local_keys()?;
674    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
675    let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
676    let mut list = fetch_invite_list(transport, &community.relays).await.unwrap_or_default();
677    if !list.entries.iter().any(|e| e.token == token_hex) {
678        list.entries.push(invite::InviteEntry {
679            token: token_hex,
680            signer_sk: minted.link_signer.secret_key().to_secret_hex(),
681            community_id: cid_hex.clone(),
682            url: minted.url.clone(),
683            label: None,
684            created_at: now_ms() / 1000,
685            expires_at: None,
686            extra: Default::default(),
687        });
688    }
689    if !session.is_valid() {
690        return Err("account changed during link record".to_string());
691    }
692    let event = invite::build_invite_list_event(&me, &list).map_err(|e| e.to_string())?;
693    transport.publish(&event, &community.relays).await?;
694    let signers = live_signers_for(&list, &cid_hex);
695    publish_invite_registry(transport, community, &session, &signers).await
696}
697
698/// Revoke a public link by its token hex (CORD-05 §2/§5): re-post its coordinate as a
699/// revocation tombstone (retiring the bundle behind the URL, so a fetcher finds the
700/// grave), tombstone the Invite List entry, and refresh the Registry. Retiring the
701/// LAST live link empties the Registry → the community reads Private (a Refounding is
702/// the owner's separate read-cut).
703pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
704    let session = SessionGuard::capture();
705    let me = local_keys()?;
706    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
707    let mut list = fetch_invite_list(transport, &community.relays).await.ok_or("no invite list found to revoke from")?;
708    let entry = list
709        .entries
710        .iter()
711        .find(|e| e.token == token_hex && e.community_id == cid_hex)
712        .cloned()
713        .ok_or("no such link in the invite list")?;
714    // Re-post the bundle coordinate as a revocation tombstone (creator-signed).
715    let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
716    let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
717    if !session.is_valid() {
718        return Err("account changed during revoke".to_string());
719    }
720    transport.publish_durable(&revocation, &community.relays).await?;
721    // Tombstone the Invite List entry (permanent — a stale device can't resurrect it).
722    list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
723    list.entries.retain(|e| e.token != token_hex);
724    let event = invite::build_invite_list_event(&me, &list).map_err(|e| e.to_string())?;
725    transport.publish(&event, &community.relays).await?;
726    let signers = live_signers_for(&list, &cid_hex);
727    publish_invite_registry(transport, community, &session, &signers).await?;
728    // Drop the local mirror row (sibling of the mint-time save) — only if still our session.
729    if session.is_valid() {
730        let _ = crate::db::community::delete_public_invite(token_hex);
731    }
732    Ok(())
733}
734
735/// Refresh every live public link's bundle behind its stable URL (CORD-05 §2) — e.g.
736/// after a Rekey/Refounding rolled the keys — by re-posting the bundle at the same
737/// coordinate with the CURRENT community state, so a link shared once keeps working
738/// across rotations. Best-effort.
739pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
740    let session = SessionGuard::capture();
741    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
742    // Fetch inline (not via fetch_invite_list) so a TRANSPORT FAILURE propagates as
743    // Err — the caller (a post-refounding refresh) must be able to retry, or live
744    // links keep serving the PRE-refound root and new joiners land on the dead
745    // epoch. A genuinely-empty list is Ok (nothing to refresh).
746    let me = local_keys()?;
747    let query = Query {
748        kinds: vec![super::kind::INVITE_LIST],
749        authors: vec![me.public_key().to_hex()],
750        limit: Some(4),
751        ..Default::default()
752    };
753    let events = transport.fetch(&query, &community.relays).await?;
754    let list = events
755        .into_iter()
756        .filter_map(|e| invite::parse_invite_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
757        .max_by_key(|(at, _)| *at)
758        .map(|(_, l)| l);
759    let Some(list) = list else {
760        return Ok(());
761    };
762    let creator = local_keys()?.public_key();
763    let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
764    for entry in &list.entries {
765        if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
766            continue;
767        }
768        let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
769        let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
770        let bundle = bundle_of(community, Some(creator), entry.expires_at, entry.label.clone());
771        let bundle_key = super::derive::invite_bundle_key(&token);
772        if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
773            if !session.is_valid() {
774                return Err("account changed during link refresh".to_string());
775            }
776            let _ = transport.publish_durable(&event, &community.relays).await;
777        }
778    }
779    Ok(())
780}
781
782/// Whether this community is PUBLIC (CORD-05 §5): fold every creator's Registry
783/// (vsk-8) that its author is authorized for (`CREATE_INVITE`, bound to their
784/// coordinate) into an aggregate live-link set — non-empty ⇒ a live link exists ⇒
785/// Public; empty ⇒ Private. Retiring the last link is what flips it back.
786pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
787    use crate::community::roles::Permissions;
788    use std::collections::BTreeMap;
789    let Ok(owner) = community.owner() else { return false };
790    let owner_hex = owner.to_hex();
791    let cid = community.id();
792    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
793    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
794        .unwrap_or_default()
795        .into_iter()
796        .filter(|(_, f)| f.0 == community.root_epoch.0)
797        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
798        .collect();
799    let control = control_group_key(&community.community_root, cid, community.root_epoch);
800    let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![control.pk_hex()], limit: Some(FOLLOW_PAGE), ..Default::default() };
801    let editions: Vec<ParsedEdition> = transport
802        .fetch(&query, &community.relays)
803        .await
804        .unwrap_or_default()
805        .iter()
806        .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(ed, _)| ed))
807        .collect();
808    let authority = fold_authority(community, &editions, &floors);
809
810    let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
811    for e in &editions {
812        if e.vsk == vsk::INVITE_LINKS {
813            by_eid.entry(e.entity_id).or_default().push(e);
814        }
815    }
816    for (eid, group) in &by_eid {
817        let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
818        let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
819        let head = group[hi];
820        // The creator must hold CREATE_INVITE AND own this coordinate.
821        if !authority.roles.is_authorized(&head.author.to_hex(), Some(&owner_hex), Permissions::CREATE_INVITE) {
822            continue;
823        }
824        if super::derive::invite_links_locator(cid, &head.author.to_bytes()) != *eid {
825            continue;
826        }
827        if invite::parse_registry_content(&head.content).map(|s| !s.is_empty()).unwrap_or(false) {
828            return true;
829        }
830    }
831    false
832}
833
834/// Accept an already-unwrapped bundle: verify the owner commitment AND that the
835/// delivered community_root is genuinely the owner's, persist the community, and
836/// announce a Guestbook Join (with invite attribution). Shared tail of both accept
837/// paths. Takes the caller's `SessionGuard` (captured BEFORE any network fetch the
838/// caller did) so the `is_valid()` gate straddles that I/O.
839async fn accept_bundle<T: Transport + ?Sized>(
840    transport: &T,
841    session: &SessionGuard,
842    bundle: &CommunityInvite,
843    invited_by: Option<PublicKey>,
844) -> Result<CommunityV2, String> {
845    let me = local_keys()?;
846    let at_ms = now_ms();
847    // Expiry gate: a past invite still previews but must not join (CORD-05 §1).
848    if bundle.expired(at_ms) {
849        return Err("this invite has expired".to_string());
850    }
851    // `from_bundle` re-validates bounds + the owner commitment fail-closed.
852    let community = CommunityV2::from_bundle(bundle, at_ms)?;
853
854    // Authenticate the delivered community_root before trusting it. The owner
855    // commitment proves WHO the owner is, but community_root (and channel keys) are
856    // NOT in that commitment, so a forged invite can pair a real (id, owner, salt)
857    // with an attacker-chosen root and silently partition the joiner onto planes
858    // only the attacker controls. Requiring the owner's genesis to open under the
859    // delivered root closes that eclipse; also reconciles channel classification.
860    // A preview verified the SAME (id, root) moments ago → reuse its fold instead
861    // of re-walking the plane (the bundle re-fetch above kept the revocation gate).
862    let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
863        v.session.is_valid()
864            && v.at.elapsed() < VERIFIED_PREVIEW_TTL
865            && v.community_id == community.id().0
866            && v.community_root == community.community_root
867    });
868    let (community, join_heads) = match handoff {
869        Some(v) => {
870            let mut c = v.folded;
871            // The preview holds no acquisition time — stamp the JOIN's.
872            c.created_at_ms = at_ms;
873            (c, v.heads)
874        }
875        None => verify_owner_root_and_reconcile(transport, community).await?,
876    };
877
878    // A dissolved community is a grave (CORD-02 §9): refuse to join it.
879    if is_dissolved(transport, &community).await {
880        return Err("this community has been dissolved".to_string());
881    }
882
883    // The account must not have swapped since the guard was captured (which was
884    // before any fetch the caller / the verify above performed) — else we'd write
885    // A's join into B.
886    if !session.is_valid() {
887        return Err("account changed during join".to_string());
888    }
889    // Seed the verified heads as the initial refuse-downgrade floor BEFORE the
890    // community row lands (floors-then-state, so a mid-seed error can't leave saved
891    // state outrunning its floor); the first post-join follow then can't persist a
892    // state below what this join already showed.
893    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
894    for h in &join_heads {
895        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)?;
896    }
897    crate::db::community::save_community_v2(&community)?;
898    // Archive the joined root at its epoch, so this member reads Public-channel
899    // history from their join epoch onward across later Refoundings (CORD-03 §3).
900    let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
901    // Same for each granted Private-channel key: the archive is what lets its
902    // history stay readable after the channel rotates away from this key.
903    for ch in &community.channels {
904        if let (true, Some(key)) = (ch.private, ch.key) {
905            let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
906        }
907    }
908
909    // Announce our Guestbook Join, echoing the invite attribution when present.
910    let attribution = invited_by
911        .map(|p| p.to_hex())
912        .or_else(|| bundle.creator_npub.clone())
913        .zip(Some(bundle.label.clone().unwrap_or_default()));
914    let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
915    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
916    let join_rumor = guestbook::build_join_rumor(me.public_key(), attr_ref, at_ms);
917    if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor(&join_rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000)) {
918        let _ = transport.publish(&join_wrap, &community.relays).await;
919    }
920
921    // Record the membership across devices (CORD-02 §8) — best-effort.
922    let _ = republish_community_list(transport, Some(community.id())).await;
923    Ok(community)
924}
925
926/// Prove the delivered `community_root` is genuinely the owner's, and reconcile
927/// channel classification from the owner's editions. `community_id` commits only
928/// to `(owner_xonly, owner_salt)` — both semi-public (they ride every bundle and
929/// every synced Community List) — so a forged invite can present a real community's
930/// id/owner/salt with an attacker-chosen root; every plane then derives from that
931/// root, silently eclipsing the joiner onto attacker-controlled addresses while the
932/// owner commitment still "verifies". The defense: the owner's genesis metadata
933/// edition (vsk-0, `eid == community_id`) only opens under the AUTHENTIC root — an
934/// attacker can't forge the owner's seal — so its presence on the control plane
935/// derived from the delivered root proves that root. Fail-closed: no owner genesis
936/// (forged invite, or relays unreachable) → refuse to join. On success, folds the
937/// owner's authoritative editions to heal a bundle that misclassified a channel.
938async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
939    transport: &T,
940    community: CommunityV2,
941) -> Result<(CommunityV2, Vec<FoldedHead>), String> {
942    let owner = community.owner()?;
943    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
944    let control_pk = control.pk_hex();
945
946    // AUTH-gating relays (ditto-relay's default gates kind-1059) serve a plane's
947    // wraps ONLY to a connection authenticated AS the stream key — Concord's
948    // group-addressed wraps aren't p-tagged to the joiner, so the login alone can't
949    // satisfy the gate and the control plane reads back empty. Register this
950    // community's stream keys + start the challenge responder so the fetch below
951    // (whose REQ triggers the relay's AUTH challenge) reads the plane after auth.
952    super::streamauth::prime(&community);
953
954    // Authenticity = the owner's GENESIS metadata edition (vsk-0, `eid ==
955    // community_id`) at the root-derived control plane. The genesis eid pins it to
956    // THIS community, and it lives ONLY under the real root — so a forged root can't
957    // produce one: an edition's seal carries no community binding, but another
958    // community's genesis has a different eid, and this community's own genesis is
959    // unreadable without its real root (which the forger lacks). ("Any owner edition"
960    // is NOT sound: an owner sig from any co-owned community, rewrapped onto the fake
961    // plane, would pass — reopening the eclipse.) The residual — a T-member replaying
962    // T's genesis onto a fake root to MITM another T-joiner — is closed only by
963    // binding the root into community_id (protocol, deferred).
964    //
965    // Seed `until` with a FAR-FUTURE constant (NOT now-based): `until.is_some()` takes
966    // the transport's AUTHORITATIVE drain-ALL-relays path (an open `until` returns only
967    // a fast relay's partial window and misses a genesis on a lagging relay — routine
968    // over Tor), while a constant beyond any real created_at clips NOTHING — so neither
969    // a clock-skewed future-dated genesis nor a >1h-slow-clock joiner is excluded (a
970    // now-based bound could clip either). Break on an EMPTY page (a short page is a
971    // relay cap). A forged root walks to exhaustion and rejects; a flood/deep plane
972    // that buries the genesis past the walk is the deferred protocol residual.
973    const PAGE: usize = 500;
974    const MAX_PAGES: usize = 4;
975    const FAR_FUTURE_SECS: u64 = 4_102_444_800; // ~year 2100 — above any real edition, safe as a relay `until`.
976    let mut editions: Vec<ParsedEdition> = Vec::new();
977    let mut found_genesis = false;
978    let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
979    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
980    for _ in 0..MAX_PAGES {
981        let query = Query {
982            kinds: vec![stream::KIND_WRAP],
983            authors: vec![control_pk.clone()],
984            until,
985            limit: Some(PAGE),
986            ..Default::default()
987        };
988        let wraps = transport.fetch(&query, &community.relays).await?;
989        // INCLUSIVE `until` + wrap-id dedup: a `-1` step can skip same-second
990        // siblings at a page boundary (and the genesis with them); re-served
991        // boundary events are free, and no-new-events means exhausted.
992        let mut oldest = u64::MAX;
993        let mut fresh = 0usize;
994        for w in &wraps {
995            if !seen_wraps.insert(w.id) {
996                continue;
997            }
998            fresh += 1;
999            oldest = oldest.min(w.created_at.as_secs());
1000            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1001                if ed.author == owner {
1002                    if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1003                        found_genesis = true;
1004                    }
1005                    editions.push(ed);
1006                }
1007            }
1008        }
1009        if found_genesis || fresh == 0 {
1010            break; // authenticated (the owner genesis), or the relay is exhausted.
1011        }
1012        until = Some(oldest);
1013    }
1014    if !found_genesis {
1015        return Err(
1016            "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"
1017                .to_string(),
1018        );
1019    }
1020    // Join-time reconcile: the joiner holds no floors yet (empty map → bootstrap per
1021    // entity). The heads this fold verified are returned for the caller to SEED as
1022    // the initial floor once the community row is saved — without that, the first
1023    // post-join follow would bootstrap floor-less and could persist a state BELOW
1024    // what this join already verified and showed.
1025    // Join-time reconcile folds only the owner's editions (genesis-authenticated
1026    // above), and the owner is supreme — so owner-only authority suffices. The full
1027    // roster (admins) folds on the first post-join follow_control.
1028    let empty_floors = Floors::new();
1029    let authority = AuthoritySet::owner_only();
1030    let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1031    Ok((fold.updated.unwrap_or(community), fold.heads))
1032}
1033
1034/// Accept a Direct Invite: unwrap the 3313 giftwrap (Schnorr-verifying the seal),
1035/// then run the shared accept path. The recipient's consent IS this call. No
1036/// network await precedes the accept, so the guard captured here suffices.
1037pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1038    let session = SessionGuard::capture();
1039    let me = local_keys()?;
1040    let (inviter, bundle) = invite::unwrap_direct_invite(wrap, &me).map_err(|e| e.to_string())?;
1041    accept_bundle(transport, &session, &bundle, Some(inviter)).await
1042}
1043
1044/// Accept a PARKED Direct Invite from its stored bundle JSON (the wrap was already
1045/// unwrapped + owner-verified at park time). Re-parses through the same fail-closed
1046/// bundle validation, then runs the shared accept path (which re-verifies the owner
1047/// root over the network). `inviter_hex` is the parked seal signer, for Guestbook
1048/// Join attribution.
1049pub async fn accept_parked_invite<T: Transport + ?Sized>(
1050    transport: &T,
1051    bundle_json: &str,
1052    inviter_hex: Option<&str>,
1053) -> Result<CommunityV2, String> {
1054    let session = SessionGuard::capture();
1055    let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1056    let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1057    accept_bundle(transport, &session, &bundle, invited_by).await
1058}
1059
1060/// Fetch + decrypt the newest Live bundle at a public link's coordinate
1061/// (`(33301, link_signer, "")`). **Revocation is authoritative-if-present**: if
1062/// ANY signer-valid tombstone is among the fetched events, refuse — never trust
1063/// fetch ordering (a cross-relay union has no global newest-first sort, so a
1064/// stale Live could otherwise win a partial-propagation race). Otherwise pick
1065/// the newest valid Live by `created_at`. Read-only.
1066pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1067    let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1068    // NO `#d` filter, even though the coordinate's `d` is empty (CORD-05 §2). Relays disagree on
1069    // indexing an empty tag value: some answer the REQ and then never EOSE, so the fetch burns its
1070    // whole union grace on every invite. The per-link signer pins the coordinate on its own (it
1071    // signs nothing else), and `parse_bundle_event` re-checks the empty `d` locally.
1072    let query = Query {
1073        kinds: vec![super::kind::INVITE_BUNDLE],
1074        authors: vec![parsed.link_signer.to_hex()],
1075        ..Default::default()
1076    };
1077    let relays = if parsed.bootstrap_relays.is_empty() {
1078        invite::stock_relays()
1079    } else {
1080        parsed.bootstrap_relays.clone()
1081    };
1082    let events = transport.fetch(&query, &relays).await?;
1083    let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1084
1085    // Scan EVERY event: a tombstone beats a Live unconditionally (order-independent).
1086    let mut newest_live: Option<(u64, CommunityInvite)> = None;
1087    for event in &events {
1088        match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1089            Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1090            Ok(invite::BundleState::Live(bundle)) => {
1091                let at = event.created_at.as_secs();
1092                if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1093                    newest_live = Some((at, *bundle));
1094                }
1095            }
1096            Err(_) => {} // a foreign/garbage event at the coordinate — ignore.
1097        }
1098    }
1099    newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1100}
1101
1102/// The most recent owner-root verification a PREVIEW completed, handed to a join
1103/// so accepting seconds later doesn't re-walk the control plane. Single-slot,
1104/// short-lived, session-guarded, and keyed on `(community_id, community_root)` —
1105/// a different delivered root never matches. The join's own bundle re-fetch is
1106/// untouched, so the revocation gate always runs live.
1107struct VerifiedPreview {
1108    session: SessionGuard,
1109    at: std::time::Instant,
1110    community_id: [u8; 32],
1111    community_root: [u8; 32],
1112    folded: CommunityV2,
1113    heads: Vec<FoldedHead>,
1114}
1115static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
1116const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
1117
1118/// Read-only rich preview of a public link: the decrypted bundle plus the LATEST
1119/// display metadata folded live from the Control Plane (a v2 bundle deliberately
1120/// carries no icon — the fold is the authority). Owner-root verification rides
1121/// the fold, so a forged-root link can't render a convincing preview; on a
1122/// fold/transport failure the bundle snapshot is the fallback. Nothing persists
1123/// — the caller hasn't joined.
1124pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1125    let bundle = fetch_public_bundle(transport, url).await?;
1126    preview_bundle(transport, &bundle).await
1127}
1128
1129/// The fold half of [`preview_public_link`], over an already-fetched bundle. Split out so a caller
1130/// that only needs the community's IDENTITY can read it off the bundle (it is self-certifying) and
1131/// skip the Control-Plane walk entirely — the walk is the join gate, and `accept_public_link` runs
1132/// it again regardless.
1133pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
1134    let community = CommunityV2::from_bundle(bundle, 0)?;
1135    match verify_owner_root_and_reconcile(transport, community.clone()).await {
1136        Ok((folded, heads)) => {
1137            *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
1138                session: SessionGuard::capture(),
1139                at: std::time::Instant::now(),
1140                community_id: folded.id().0,
1141                community_root: folded.community_root,
1142                folded: folded.clone(),
1143                heads,
1144            });
1145            Ok(folded)
1146        }
1147        Err(_) => Ok(community),
1148    }
1149}
1150
1151/// Accept a public invite link: fetch its bundle (revocation-aware) and join.
1152pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1153    // Capture BEFORE the network fetch so the join's is_valid() gate straddles it.
1154    let session = SessionGuard::capture();
1155    let bundle = fetch_public_bundle(transport, url).await?;
1156    if !session.is_valid() {
1157        return Err("account changed during join".to_string());
1158    }
1159    accept_bundle(transport, &session, &bundle, None).await
1160}
1161
1162/// Leave a community: publish a Guestbook Leave and tear down the local hold.
1163pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1164    let session = SessionGuard::capture();
1165    let me = local_keys()?;
1166    let at_ms = now_ms();
1167    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1168    let leave_rumor = guestbook::build_leave_rumor(me.public_key(), at_ms);
1169    if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor(&leave_rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000)) {
1170        let _ = transport.publish(&wrap, &community.relays).await;
1171    }
1172    if !session.is_valid() {
1173        return Err("account changed during leave".to_string());
1174    }
1175    // Tombstone the membership across devices (CORD-02 §8) BEFORE the local delete,
1176    // to the leaving community's own relays (it's about to be gone locally) —
1177    // best-effort.
1178    let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
1179    // The tombstone publish straddled an await — never delete from a swapped-in DB.
1180    if !session.is_valid() {
1181        return Err("account changed during leave".to_string());
1182    }
1183    crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1184    Ok(())
1185}
1186
1187/// Cooperative Kick (CORD-04 §6, Guestbook plane): name the target; every reader
1188/// honors it iff the signer holds KICK and strictly outranks them (the coalesce's
1189/// `can_kick`), so publishing without authority is inert. A kicked member may
1190/// rejoin with a fresh invite — cryptographic severance is the ban/refound path.
1191pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1192    let session = SessionGuard::capture();
1193    let me = local_keys()?;
1194    // Fast local pre-check; readers re-verify independently. The `vac` citation
1195    // rides with the deferred citation-completeness pass (owner needs none).
1196    let authority = fetch_authority(transport, community).await;
1197    let owner_hex = community.owner()?.to_hex();
1198    if !authority.roles.can_act_on_member(
1199        &me.public_key().to_hex(),
1200        Some(&owner_hex),
1201        &target.to_hex(),
1202        crate::community::roles::Permissions::KICK,
1203    ) {
1204        return Err("not authorized to kick this member".to_string());
1205    }
1206    let at_ms = now_ms();
1207    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1208    let rumor = guestbook::build_kick_rumor(me.public_key(), *target, None, at_ms);
1209    let (wrap, _) = guestbook::seal_guestbook_rumor(&rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000))
1210        .map_err(|e| e.to_string())?;
1211    if !session.is_valid() {
1212        return Err("account changed before send".to_string());
1213    }
1214    transport.publish(&wrap, &community.relays).await?;
1215    Ok(())
1216}
1217
1218/// A community's folded, delegation-authorized authority — the on-demand read
1219/// view (a paged control-plane fetch + fold, nothing persisted). `roles` is the
1220/// owner-seeded authorized roster (shared algebra with v1); `banned` the
1221/// enforced banlist. `floored`/`head_entities` let a writer detect a WITHHELD
1222/// entity (floored locally but no head folded) before replacing it blind.
1223pub struct AuthorityView {
1224    pub roles: crate::community::roles::CommunityRoles,
1225    pub banned: std::collections::BTreeSet<String>,
1226    /// Any authority entity's fold hit a floor gap (withheld / evicted link).
1227    pub gapped: bool,
1228    /// Entity hexes holding a persisted floor at this epoch (all vsk kinds).
1229    pub floored: std::collections::BTreeSet<String>,
1230    /// Authority entities (role/grant/banlist) that folded a head this fetch.
1231    pub head_entities: std::collections::BTreeSet<String>,
1232}
1233
1234/// Fetch + fold the community's current authority (CORD-04), paging older like
1235/// `follow_control` while the fold is gapped so a busy control plane can't push
1236/// the roster off the newest window. A fetch failure degrades fail-safe:
1237/// owner-only authority plus the PERSISTED banlist — nobody gains standing from
1238/// an outage, and a ban never lifts on withheld data.
1239pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1240    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1241    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1242        .unwrap_or_default()
1243        .into_iter()
1244        .filter(|(_, f)| f.0 == community.root_epoch.0)
1245        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1246        .collect();
1247    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1248
1249    let mut editions: Vec<ParsedEdition> = Vec::new();
1250    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1251    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1252    let mut oldest: Option<u64> = None;
1253    let mut until: Option<u64> = None;
1254    // Seed from an EMPTY fold, not owner_only(): a fold over zero editions yields
1255    // owner-only roles AND retains the PERSISTED banlist. So a first-page transport
1256    // error returns the stored bans (fail-safe), never an empty banlist that would
1257    // silently un-ban on withheld data.
1258    let mut a = fold_authority(community, &[], &floors);
1259    for _ in 0..FOLLOW_MAX_PAGES {
1260        let query = Query {
1261            kinds: vec![stream::KIND_WRAP],
1262            authors: vec![control.pk_hex()],
1263            until,
1264            limit: Some(FOLLOW_PAGE),
1265            ..Default::default()
1266        };
1267        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
1268        let mut fresh = 0usize;
1269        for w in &wraps {
1270            if !seen_wraps.insert(w.id) {
1271                continue;
1272            }
1273            fresh += 1;
1274            let at = w.created_at.as_secs();
1275            if oldest.is_none_or(|o| at < o) {
1276                oldest = Some(at);
1277            }
1278            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1279                if seen.insert(ed.inner_id) {
1280                    editions.push(ed);
1281                }
1282            }
1283        }
1284        a = fold_authority(community, &editions, &floors);
1285        if !a.gapped || fresh == 0 {
1286            break;
1287        }
1288        until = oldest;
1289    }
1290    AuthorityView {
1291        roles: a.roles,
1292        banned: a.banned,
1293        gapped: a.gapped,
1294        floored: floors.keys().cloned().collect(),
1295        head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
1296    }
1297}
1298
1299/// Page the Guestbook plane newest-to-oldest, stopping once a page's oldest wrap
1300/// falls below `since_secs` (everything older is already held) or the plane is
1301/// exhausted. Returns the parsed events at/after the window plus the newest wrap
1302/// time seen (the caller's next cursor; `since_secs` when nothing newer arrived).
1303///
1304/// PAGE bound rationale: a single 500-window silently drops a member whose Join
1305/// aged out (organic growth, or an insider flooding throwaway Joins), and
1306/// `refound_community` consumes the fold as its rekey recipient set — a dropped
1307/// member is SEVERED. Beyond this depth a community needs sharding (documented);
1308/// the granted-member union in [`fold_members`] is the consensus-complete
1309/// backstop regardless of Guestbook depth.
1310async fn fetch_guestbook_events<T: Transport + ?Sized>(
1311    transport: &T,
1312    community: &CommunityV2,
1313    since_secs: u64,
1314) -> Result<(Vec<guestbook::GuestbookEvent>, u64), String> {
1315    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1316    const GB_PAGE: usize = 500;
1317    const GB_MAX_PAGES: usize = 12;
1318    let mut events = Vec::new();
1319    let mut newest: u64 = since_secs;
1320    let mut seen: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1321    let mut until: Option<u64> = None;
1322    let mut oldest: Option<u64> = None;
1323    for _ in 0..GB_MAX_PAGES {
1324        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_group.pk_hex()], until, limit: Some(GB_PAGE), ..Default::default() };
1325        let wraps = transport.fetch(&query, &community.relays).await?;
1326        let mut fresh = 0usize;
1327        for wrap in &wraps {
1328            if !seen.insert(wrap.id) {
1329                continue;
1330            }
1331            fresh += 1;
1332            let at = wrap.created_at.as_secs();
1333            if oldest.is_none_or(|o| at < o) {
1334                oldest = Some(at);
1335            }
1336            if at > newest {
1337                newest = at;
1338            }
1339            // Older than the cursor window — already held; skip the decrypt.
1340            if at < since_secs {
1341                continue;
1342            }
1343            if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
1344                if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
1345                    events.push(ev);
1346                }
1347            }
1348        }
1349        if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
1350            break;
1351        }
1352        match oldest {
1353            Some(o) if o > 0 => until = Some(o),
1354            _ => break,
1355        }
1356    }
1357    Ok((events, newest))
1358}
1359
1360/// The shared membership fold: coalesce Guestbook events under the community's
1361/// authority (owner-supreme kicks, refounder snapshots), union observed authors
1362/// plus every roster grantee, subtract the banlist, and pin the proven owner.
1363/// One implementation, so the live and stored reads can't drift.
1364fn fold_members(
1365    community: &CommunityV2,
1366    events: &[guestbook::GuestbookEvent],
1367    mut observed: std::collections::BTreeMap<PublicKey, u64>,
1368    roles: &crate::community::roles::CommunityRoles,
1369    banlist: &std::collections::BTreeSet<PublicKey>,
1370) -> Result<Vec<PublicKey>, String> {
1371    let owner = community.owner()?;
1372    let owner_hex = owner.to_hex();
1373
1374    // CONSENSUS-COMPLETE backstop: every member the folded roster GRANTS a role to
1375    // is provably a member (a Grant binds member_xonly, CORD-02 A.6) — count them
1376    // even if their Join aged out of the Guestbook entirely and they never posted.
1377    // This is what keeps a Refounding from severing a lurking admin. `observed`
1378    // carries them at ts 0 (presence, not recency); the banlist subtraction below
1379    // still removes a banned grantee whose grant wasn't yet stripped.
1380    for g in &roles.grants {
1381        if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
1382            observed.entry(pk).or_insert(0);
1383        }
1384    }
1385
1386    // Snapshot authority (CORD-02 §5): a refounding rolls `root_epoch` and re-seeds the
1387    // new epoch's Guestbook with a 3312 snapshot of the survivors. Refounding is OWNER-only,
1388    // so the owner is the refounder whose snapshot is honored — without this, every silent
1389    // survivor vanishes from the memberlist until they re-post. A genesis community
1390    // (root_epoch 0) has no refounder, hence no snapshot power.
1391    let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1392    // Kick authority (CORD-04 §6): the signer must hold KICK AND strictly outrank the
1393    // target (the owner is supreme; equal cannot kick equal).
1394    let can_kick = |actor: &PublicKey, target: &PublicKey| {
1395        roles.can_act_on_member(&actor.to_hex(), Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1396    };
1397    let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
1398    let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist);
1399    // The owner is a member by definition, independent of any fetched Join.
1400    if !banlist.contains(&owner) {
1401        members.insert(owner);
1402    }
1403    Ok(members.into_iter().collect())
1404}
1405
1406/// Catch the persisted Guestbook up from its stored cursor (a fresh hold seeds
1407/// from zero). The fetch straddles the network, so the session re-checks before
1408/// the store writes. Returns the events that were NEW to the store — the caller
1409/// surfaces them (presence lines) and refreshes on non-empty.
1410pub async fn sync_guestbook<T: Transport + ?Sized>(
1411    transport: &T,
1412    community: &CommunityV2,
1413    session: &SessionGuard,
1414) -> Result<Vec<guestbook::GuestbookEvent>, String> {
1415    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1416    let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1417    // Overlap one second so a same-second boundary event can't slip the cursor;
1418    // the rumor-id merge below dedups the re-fetched edge.
1419    let since = cursor.saturating_sub(1);
1420    let (fresh, newest) = fetch_guestbook_events(transport, community, since).await?;
1421    if !session.is_valid() {
1422        return Err("account changed during guestbook sync".to_string());
1423    }
1424    let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
1425    let mut added = Vec::new();
1426    for ev in fresh {
1427        if !known.contains(&ev.rumor_id) {
1428            events.push(ev.clone());
1429            added.push(ev);
1430        }
1431    }
1432    if !added.is_empty() || newest > cursor {
1433        crate::db::community::set_guestbook(&cid_hex, &events, newest.max(cursor))?;
1434    }
1435    Ok(added)
1436}
1437
1438/// Fold ONE live guestbook event into the store (the realtime path — no fetch).
1439/// Returns whether it was new.
1440pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
1441    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1442    let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1443    if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
1444        return Ok(false);
1445    }
1446    events.push(ev);
1447    crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
1448    Ok(true)
1449}
1450
1451/// The memberlist from LOCAL state only: the persisted Guestbook, plus locally
1452/// observed authors (the synced events DB), plus roster grantees, minus the
1453/// banlist. Instant and offline-correct; [`sync_guestbook`] (post-join, boot,
1454/// reconnect, live ingest) keeps the store current. The live [`memberlist`]
1455/// remains the authoritative walk — a refounding's rekey recipient set must
1456/// never trust a possibly-stale store.
1457pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
1458    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1459    let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1460    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
1461    for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
1462        if let Ok(pk) = PublicKey::parse(&npub) {
1463            observed.insert(pk, last_active_secs.saturating_mul(1000));
1464        }
1465    }
1466    let roles = crate::db::community::get_community_roles(&cid_hex)?;
1467    let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
1468        .unwrap_or_default()
1469        .iter()
1470        .filter_map(|h| PublicKey::from_hex(h).ok())
1471        .collect();
1472    fold_members(community, &events, observed, &roles, &banlist)
1473}
1474
1475/// Fold the Complete Memberlist from the Guestbook plane. The proven owner is
1476/// ALWAYS a member (derived from the self-certifying community_id — no network,
1477/// so a lost/evicted genesis Join can't drop them). Observed authors — anyone
1478/// seen publishing on a channel — are folded in FORWARD-only per CORD-02 §5, so a
1479/// member whose Join was lost still counts.
1480pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
1481    let (events, _newest) = fetch_guestbook_events(transport, community, 0).await?;
1482    // Observed authors: fold each held channel's recent authorship (real author +
1483    // newest ms), so a member who posted but whose Join was lost is still counted.
1484    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
1485    for ch in &community.channels {
1486        if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
1487            for f in &page {
1488                let e = observed.entry(f.event.opened().author).or_insert(0);
1489                *e = (*e).max(f.event.opened().at_ms);
1490            }
1491        }
1492    }
1493
1494    // Fold the Control Plane roster + banlist (CORD-04) for Kick authority and the
1495    // ban subtraction. A control fetch failure degrades to owner-only authority + no
1496    // bans (fail-open on availability is safe here: a Kick still needs a real signer,
1497    // and a missed ban only fails to HIDE, never to wrongly admit authority).
1498    let authority = fetch_authority(transport, community).await;
1499    // The authorized banlist, as pubkeys (a malformed hex entry is simply dropped).
1500    let banlist: std::collections::BTreeSet<PublicKey> =
1501        authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
1502    fold_members(community, &events, observed, &authority.roles, &banlist)
1503}
1504
1505// ── Dissolution (CORD-02 §9) ─────────────────────────────────────────────────
1506
1507/// Owner dissolution / "Delete Community" (CORD-02 §9): publish the terminal
1508/// tombstone at the dissolved plane (`community_id`-derived, epoch-free, so every
1509/// past or present member resolves the same grave and a Refounding can never strand
1510/// it). The tombstone's presence IS the state; only the owner's seal counts.
1511/// Irreversible — on success the local hold is sealed read-only.
1512pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1513    let session = SessionGuard::capture();
1514    let me = local_keys()?;
1515    if community.owner()? != me.public_key() {
1516        return Err("only the owner can dissolve a community".to_string());
1517    }
1518    let at = now_ms() / 1000;
1519    let rumor = super::dissolution::dissolved_tombstone_rumor(me.public_key(), community.id(), at);
1520    let wrap = super::dissolution::seal_dissolved(&rumor, community.id(), &me, Timestamp::from_secs(at)).map_err(|e| e.to_string())?;
1521    if !session.is_valid() {
1522        return Err("account changed during dissolve".to_string());
1523    }
1524    // Durable broadcast: death must propagate (a rekey racing a dissolution loses).
1525    transport.publish_durable(&wrap, &community.relays).await?;
1526    crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1527    Ok(())
1528}
1529
1530/// Whether a valid owner-signed dissolution tombstone exists for this community on
1531/// its relays (CORD-02 §9). A join refuses a dead community, and a live follow seals
1532/// on sight. Fail-OPEN on a fetch error (absence of proof is not death), but any
1533/// owner-verified tombstone found is authoritative.
1534pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1535    let group = super::derive::dissolved_group_key(community.id());
1536    let query = Query {
1537        kinds: vec![stream::KIND_WRAP],
1538        authors: vec![group.pk_hex()],
1539        limit: Some(20),
1540        ..Default::default()
1541    };
1542    let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
1543        return false;
1544    };
1545    wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
1546}
1547
1548// ── Refounding (CORD-06 §3) ──────────────────────────────────────────────────
1549
1550/// Owner/admin Refounding (CORD-06 §3): roll the `community_root` to
1551/// cryptographically remove `removed` from a Private community (a Ban's read-cut).
1552/// Compacts the Control Plane under the new root (re-wraps each head VERBATIM — the
1553/// inner owner/actor signatures survive, so no re-authoring), rekeys the base plus
1554/// every Private channel (each sealed under the PRIOR root, D2, so a base-fork loser
1555/// can still open them), and seeds the new epoch's Guestbook snapshot. Requires BAN.
1556///
1557/// **Acquire-before-commit:** the compaction is fetched + re-sealed BEFORE any
1558/// publish, and a head we can't fetch ABORTS with ZERO published state — so a
1559/// transient miss never strands a published rekey with a half-anchored plane.
1560pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
1561    let session = SessionGuard::capture();
1562    let cid = community.id();
1563    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1564    // Death wins every race: a dissolved community never re-founds (CORD-02 §9).
1565    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
1566        return Err("this community has been dissolved; it cannot be re-founded".to_string());
1567    }
1568    let me = local_keys()?;
1569    // Serialize with the follow worker for the whole rotation: the commit tail
1570    // whole-row-saves, and an unserialized concurrent follow could otherwise be
1571    // rolled back (or adopt a half-published sibling of this very rotation).
1572    let lock = super::realtime::follow_lock(cid);
1573    let _guard = lock.lock().await;
1574    // Reload the FRESHEST base state: a stale caller struct would address the rotation
1575    // under a superseded root (a base fork with no heal). The community_id is
1576    // self-certifying + stable, so re-loading by it is safe.
1577    let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
1578    let community = &fresh;
1579    let owner = community.owner()?;
1580
1581    // OWNER-ONLY send: the receive counterpart (`advance_scope`) honors ONLY the
1582    // owner's rotation, so a non-owner BAN-holder's Refounding would fork onto a root
1583    // nobody follows and fail to sever the target. A non-owner's ban still silences
1584    // (Banlist) + strips authority (Grant); the read-cut is the owner's action alone
1585    // (CORD-06 §3 partial-removal degradation). Owner ⊃ BAN (supreme), so this is the
1586    // stricter gate.
1587    if me.public_key() != owner {
1588        return Err("only the owner can re-found (the cryptographic read-cut)".to_string());
1589    }
1590
1591    // Fold the current roster: the opened editions are reused for the compaction (their
1592    // seals re-wrap under the new epoch), and the roster gates which admin-authored
1593    // heads carry forward.
1594    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
1595        .into_iter()
1596        .filter(|(_, f)| f.0 == community.root_epoch.0)
1597        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1598        .collect();
1599    let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
1600    // Page the ENTIRE control plane, not just the newest window: the compaction MUST
1601    // carry EVERY committed (floored) entity to the new epoch, so a head buried under a
1602    // flood of newer editions (100 roles + 400 grants already exceeds one page) or a
1603    // head a relay withholds can't silently drop. CORD-06 §3 mandates aborting if the
1604    // Refounder cannot fold all Control Events — a dropped Banlist would unban a member
1605    // at the new epoch a fresh joiner bootstraps.
1606    let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
1607    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1608    let mut oldest: Option<u64> = None;
1609    let mut until: Option<u64> = None;
1610    for _ in 0..FOLLOW_MAX_PAGES {
1611        let query = Query {
1612            kinds: vec![stream::KIND_WRAP],
1613            authors: vec![current_control.pk_hex()],
1614            until,
1615            limit: Some(FOLLOW_PAGE),
1616            ..Default::default()
1617        };
1618        let wraps = transport.fetch(&query, &community.relays).await?;
1619        let mut fresh = 0usize;
1620        for w in &wraps {
1621            if !seen_wraps.insert(w.id) {
1622                continue;
1623            }
1624            fresh += 1;
1625            let at = w.created_at.as_secs();
1626            if oldest.is_none_or(|o| at < o) {
1627                oldest = Some(at);
1628            }
1629            if let Ok(parsed) = control::open_control_edition(w, &current_control) {
1630                opened.push(parsed);
1631            }
1632        }
1633        // Page until every committed entity has its editions in hand (raw coverage),
1634        // so the floor-driven compaction below can fold each head.
1635        let present: std::collections::HashSet<String> =
1636            opened.iter().map(|(e, _)| crate::simd::hex::bytes_to_hex_32(&e.entity_id)).collect();
1637        if floors.keys().all(|k| present.contains(k)) || fresh == 0 {
1638            break;
1639        }
1640        until = oldest;
1641    }
1642
1643    let prev_epoch = community.root_epoch;
1644    let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
1645    let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
1646    // Mint-or-REUSE the new root, keyed by (scope, new_epoch) and archived BEFORE any
1647    // publish: a retried Refounding re-delivers the SAME root at this epoch/address, so
1648    // it can't double-mint two roots a receiver's correlation dedup would collapse into
1649    // a permanent fork (CORD-06 §3 idempotency).
1650    let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
1651    let new_control = control_group_key(&new_root, cid, new_epoch);
1652    let at = now_ms();
1653    let at_secs = at / 1000;
1654
1655    // ACQUIRE + COVERAGE GATE (CORD-06 §3 MUST): re-wrap the head of EVERY committed
1656    // (floored) entity under the new epoch — FLOOR-driven, so nothing silently drops,
1657    // including entities the metadata/roster folds don't touch (the invite Registry
1658    // vsk-8, whose coordinate survives the rekey per CORD-05 §5). A floor whose head
1659    // can't be folded (buried past the pager / withheld) ABORTS before any publish.
1660    use std::collections::BTreeMap;
1661    let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
1662    for (i, (e, _)) in opened.iter().enumerate() {
1663        by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
1664    }
1665    let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
1666    for (floor_key, floor) in &floors {
1667        // Re-wrap the AUTHORIZED head — the exact edition the persisted floor commits to
1668        // (its self_hash). The floor advances ONLY to authorized heads (author-aware fold),
1669        // so matching it is authority-correct across EVERY entity type. `fold_head`'s
1670        // version-chain TIP is author-BLIND: a member can seal a forged higher-version
1671        // edition chaining onto the floor, which the tip would carry and honest folders
1672        // then DROP as unauthorized — silently suppressing that role/grant/banlist across
1673        // the refounding. Abort if the committed head isn't served (fail-closed).
1674        let head_idx = by_eid
1675            .get(floor_key)
1676            .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
1677        let Some(head_idx) = head_idx else {
1678            return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
1679        };
1680        let (head_ed, head_os) = &opened[head_idx];
1681        let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
1682        let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
1683        carried.push((h, rewrapped));
1684    }
1685    if !session.is_valid() {
1686        return Err("account changed during re-founding acquire".to_string());
1687    }
1688
1689    // Recipients: the current members minus `removed`, plus me (multi-device).
1690    let members = memberlist(transport, community).await?;
1691    let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
1692    let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
1693    if !recipients.iter().any(|p| *p == me.public_key()) {
1694        recipients.push(me.public_key());
1695    }
1696
1697    // Base rekey blobs (the new root to each recipient), sealed under the PRIOR root.
1698    let mut base_blobs = Vec::new();
1699    for r in &recipients {
1700        base_blobs.push(
1701            super::rekey::build_blob_local(me.secret_key(), &me.public_key().to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
1702                .map_err(|e| e.to_string())?,
1703        );
1704    }
1705    let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
1706    let base_chunks =
1707        super::rekey::build_rekey_chunks_local(&me, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs)
1708            .map_err(|e| e.to_string())?;
1709
1710    // Private-channel rekeys: each mints a fresh key at its next channel-epoch, sealed
1711    // under the PRIOR root (D2). Public channels ride the base — no per-channel rekey.
1712    let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
1713    let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
1714    for ch in &community.channels {
1715        let (Some(old_key), true) = (ch.key, ch.private) else { continue };
1716        let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
1717        // Mint-or-reuse per channel too, keyed by (channel_id, next epoch) — same
1718        // retry-idempotency as the base root.
1719        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)?;
1720        let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
1721        let mut ch_blobs = Vec::new();
1722        for r in &recipients {
1723            ch_blobs.push(
1724                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)
1725                    .map_err(|e| e.to_string())?,
1726            );
1727        }
1728        let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
1729        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)
1730            .map_err(|e| e.to_string())?;
1731        channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
1732        channel_chunk_sets.push(ch_chunks);
1733    }
1734    if !session.is_valid() {
1735        return Err("account changed during re-founding prepare".to_string());
1736    }
1737
1738    // COMMIT (durable publishes only — all fetching is done). Base rekey first
1739    // (delivers the new root), then channel rekeys, then the compacted control.
1740    for c in &base_chunks {
1741        transport.publish_durable(c, &community.relays).await?;
1742    }
1743    for set in &channel_chunk_sets {
1744        for c in set {
1745            transport.publish_durable(c, &community.relays).await?;
1746        }
1747    }
1748    for (_, wrap) in &carried {
1749        transport.publish_durable(wrap, &community.relays).await?;
1750    }
1751    // Guestbook snapshot at the new epoch — best-effort (a Refounding succeeds without
1752    // it; an omitted member heals by publishing their own Join).
1753    let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
1754    let snap_id = crate::community::random_32();
1755    for rumor in guestbook::build_snapshot_rumors(me.public_key(), &recipients, snap_id, at) {
1756        if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor(&rumor, &gb_group, &me, Timestamp::from_secs(at_secs)) {
1757            let _ = transport.publish(&wrap, &community.relays).await;
1758        }
1759    }
1760
1761    // COMMIT locally, only now that the new root + compacted plane are on relays.
1762    if !session.is_valid() {
1763        return Err("account changed during re-founding commit".to_string());
1764    }
1765    if crate::db::community::community_protocol(cid)?.is_none() {
1766        return Ok(community.clone()); // left/deleted mid-rotation — don't resurrect.
1767    }
1768    // Save the new root/epoch + rekeyed channel keys in ONE tx FIRST, so a crash can
1769    // never leave the base root advanced while the channel keys lag (which would
1770    // re-derive the channel rekey address under the wrong root and orphan them).
1771    let mut updated = community.clone();
1772    updated.community_root = new_root;
1773    updated.root_epoch = new_epoch;
1774    for (id, key, ep) in &channel_updates {
1775        if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
1776            c.key = Some(*key);
1777            c.epoch = *ep;
1778        }
1779    }
1780    crate::db::community::save_community_v2(&updated)?;
1781    // Archive the new epoch key + confirm the monotonic base head (the root was already
1782    // archived by mint_or_reuse, so this is idempotent). Record the carried heads at
1783    // the NEW epoch; if a crash skips this, the epoch-filtered floors bootstrap the
1784    // compacted control on the next follow, so they self-heal.
1785    crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
1786    for (h, _) in &carried {
1787        crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
1788    }
1789    // Refresh any live public links so their bundles carry the NEW root behind the
1790    // same URL (a link shared once survives the rotation, CORD-05 §2). Idempotent,
1791    // so retry a transient failure — a stranded link lands a new joiner on the dead
1792    // pre-refound epoch, and there's no other trigger to heal it before the next
1793    // refounding. A persistent failure is logged (refound already succeeded).
1794    for attempt in 0..3u8 {
1795        match refresh_public_links(transport, &updated).await {
1796            Ok(()) => break,
1797            Err(_) if !session.is_valid() => break, // swapped — stop touching this account
1798            Err(e) if attempt == 2 => {
1799                crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
1800            }
1801            Err(_) => continue,
1802        }
1803    }
1804    Ok(updated)
1805}
1806
1807/// Mint a fresh 32-byte rotation key for `(scope, new_epoch)`, or REUSE the one
1808/// already archived from a prior (aborted) attempt — so a retried Refounding re-
1809/// delivers the SAME key at the same epoch/address instead of double-minting two roots
1810/// a receiver's correlation dedup would collapse into a permanent fork (CORD-06 §3
1811/// idempotency). Archived BEFORE the first publish; `scope` is the all-zero server-root
1812/// sentinel for a base rotation, else the channel_id hex.
1813fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
1814    if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
1815        return Ok(existing);
1816    }
1817    let fresh = crate::community::random_32();
1818    crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
1819    Ok(fresh)
1820}
1821
1822// ── The Community List (kind 13302, CORD-02 §8) ──────────────────────────────
1823
1824/// This community's MEMBERSHIP subset for the 13302 list (CORD-02 §8): never the
1825/// icon (a rehydrating device folds it from the Control Plane), never the link
1826/// fields. Only PRIVATE channel keys ride — public channels derive from the root.
1827fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
1828    let hex = crate::simd::hex::bytes_to_hex_32;
1829    let channels = community
1830        .channels
1831        .iter()
1832        .filter(|c| c.private)
1833        .filter_map(|c| {
1834            c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: hex(&k), epoch: c.epoch.0, name: c.name.clone() })
1835        })
1836        .collect();
1837    super::list::JoinMaterial {
1838        community_id: hex(&community.identity.community_id.0),
1839        owner: hex(&community.identity.owner_xonly),
1840        owner_salt: hex(&community.identity.owner_salt),
1841        community_root: hex(&community.community_root),
1842        root_epoch: community.root_epoch.0,
1843        channels,
1844        relays: community.relays.clone(),
1845        name: community.name.clone(),
1846        extra: Default::default(),
1847    }
1848}
1849
1850/// Rebuild an invite bundle from list join material, for a cross-device rehydrate
1851/// (the material IS the membership subset of a bundle). The owner root is still
1852/// verified over the network before the community is trusted (accept_bundle).
1853fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
1854    let channels = jm
1855        .channels
1856        .iter()
1857        .map(|c| invite::ChannelGrant { id: c.id.clone(), key: c.key.clone(), epoch: c.epoch, name: c.name.clone() })
1858        .collect();
1859    CommunityInvite {
1860        community_id: jm.community_id.clone(),
1861        owner: jm.owner.clone(),
1862        owner_salt: jm.owner_salt.clone(),
1863        community_root: jm.community_root.clone(),
1864        root_epoch: jm.root_epoch,
1865        channels,
1866        relays: jm.relays.clone(),
1867        name: jm.name.clone(),
1868        icon: None,
1869        expires_at: None,
1870        creator_npub: None,
1871        label: None,
1872        extra: Default::default(),
1873    }
1874}
1875
1876/// The union of every held v2 community's relays — where this account's 13302 list
1877/// lives (a fresh device that opens any held community reaches the same set).
1878fn held_v2_relays() -> Vec<String> {
1879    let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1880    if let Ok(ids) = crate::db::community::list_community_ids() {
1881        for id in ids {
1882            if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
1883                if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
1884                    set.extend(c.relays);
1885                }
1886            }
1887        }
1888    }
1889    set.into_iter().collect()
1890}
1891
1892/// Fetch this account's own 13302 Community List from `relays` (the newest wins;
1893/// a decrypt/parse failure is "no news", never a clobber of the local mirror).
1894/// Fetch this account's newest 13302 list. `Err` = the transport FAILED (a caller
1895/// must NOT drive a replaceable-event write from a failed read — it would clobber
1896/// the live list); `Ok(None)` = genuinely no list yet; `Ok(Some)` = the list.
1897async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
1898    let me = local_keys()?;
1899    let query = Query {
1900        kinds: vec![super::kind::COMMUNITY_LIST],
1901        authors: vec![me.public_key().to_hex()],
1902        limit: Some(4),
1903        ..Default::default()
1904    };
1905    let events = transport.fetch(&query, relays).await?;
1906    Ok(events
1907        .into_iter()
1908        .filter_map(|e| super::list::parse_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
1909        .max_by_key(|(at, _)| *at)
1910        .map(|(_, l)| l))
1911}
1912
1913/// Rebuild this account's 13302 from its held v2 communities, MERGE with the remote
1914/// copy (preserving tombstones, other-device entries, unknown fields), and publish.
1915/// `just_joined` is the community THIS call is recording a create/join for — the
1916/// ONLY community whose entry is (re)stamped `now`, so it beats any prior tombstone
1917/// (a deliberate re-join resurrects). Every OTHER held community that the remote
1918/// has tombstoned is left tombstoned (a sibling device's leave is NOT undone just
1919/// because we joined something else — the W1 resurrection hole). Idempotent;
1920/// best-effort — a list-publish failure never fails the membership change itself.
1921pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<(), String> {
1922    let session = SessionGuard::capture();
1923    let me = local_keys()?;
1924    let relays = held_v2_relays();
1925    if relays.is_empty() {
1926        return Ok(()); // nothing held → nothing to sync
1927    }
1928    // A FAILED remote fetch must not drive this replaceable-event write: publishing
1929    // a list built without the remote seeds would drop older-epoch backfill anchors
1930    // and re-stamp add-times (the W2 seed-regression + a resurrection window).
1931    let remote = match fetch_community_list(transport, &relays).await {
1932        Ok(r) => r.unwrap_or_default(),
1933        Err(_) => return Ok(()),
1934    };
1935    let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
1936    let now = now_ms();
1937    let mut local = super::list::CommunityList::default();
1938    for id in crate::db::community::list_community_ids()? {
1939        if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
1940            continue;
1941        }
1942        let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
1943        let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
1944        let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
1945        // A held community the remote has tombstoned (a sibling device left it) that
1946        // we are NOT currently (re)joining stays LEFT — don't re-add it, or joining a
1947        // different community would silently undo the leave everywhere.
1948        if !is_join && !remote.is_live(&cid_hex) && remote.tombstones.iter().any(|t| t.community_id == cid_hex) {
1949            continue;
1950        }
1951        // Keep an already-live entry's add time (no churn); the joined community (or a
1952        // genuinely-new one) stamps `now` so a re-join beats a stale tombstone.
1953        let added_at = if remote.is_live(&cid_hex) && !is_join {
1954            remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
1955        } else {
1956            now
1957        };
1958        let jm = join_material(&c);
1959        local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
1960    }
1961    let merged = remote.merge(&local);
1962    merged.assert_fits().map_err(|e| e.to_string())?;
1963    let event = super::list::build_list_event(&me, &merged).map_err(|e| e.to_string())?;
1964    if !session.is_valid() {
1965        return Err("account changed during community-list publish".to_string());
1966    }
1967    transport.publish(&event, &relays).await
1968}
1969
1970/// Record a permanent leave tombstone for `community_id` in the 13302, published to
1971/// `relays` (the leaving community's own, since it's about to be deleted locally).
1972async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
1973    let me = local_keys()?;
1974    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
1975    // A failed fetch here would drop other communities' entries (only the
1976    // tombstone would survive); preserve them by bailing — the leave re-records
1977    // on the next attempt, and the local teardown already happened.
1978    let mut doc = match fetch_community_list(transport, relays).await {
1979        Ok(d) => d.unwrap_or_default(),
1980        Err(e) => return Err(e),
1981    };
1982    let now = now_ms();
1983    doc.tombstones.retain(|t| t.community_id != cid_hex);
1984    doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
1985    doc.assert_fits().map_err(|e| e.to_string())?;
1986    let event = super::list::build_list_event(&me, &doc).map_err(|e| e.to_string())?;
1987    transport.publish(&event, relays).await
1988}
1989
1990/// Sync memberships from the 13302 across devices: fetch this account's list from
1991/// `bootstrap_relays` (its held communities' relays plus any caller-supplied set for
1992/// a fresh device), and JOIN every live entry not already held — reconstructing the
1993/// community from its join material and re-verifying the owner root. Returns the
1994/// newly-rehydrated communities (so the caller can subscribe + notify).
1995pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<Vec<CommunityV2>, String> {
1996    let session = SessionGuard::capture();
1997    let mut relays = held_v2_relays();
1998    relays.extend(bootstrap_relays.iter().cloned());
1999    relays.sort();
2000    relays.dedup();
2001    if relays.is_empty() {
2002        return Ok(vec![]);
2003    }
2004    let list = match fetch_community_list(transport, &relays).await {
2005        Ok(Some(l)) => l,
2006        Ok(None) | Err(_) => return Ok(vec![]),
2007    };
2008    // Receive-side teardown (the counterpart to the republish tombstone guard):
2009    // a community this device still holds but the synced list shows TOMBSTONED (a
2010    // sibling device left it) and NOT live gets torn down here, so a leave on one
2011    // device propagates to the others. A re-join would have re-added it live
2012    // (beating the tombstone), so is_live short-circuits the honest case.
2013    for t in &list.tombstones {
2014        if list.is_live(&t.community_id) {
2015            continue;
2016        }
2017        let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
2018        let id = crate::community::CommunityId(cid);
2019        if crate::db::community::load_community_v2(&id).ok().flatten().is_none() {
2020            continue; // not held — nothing to tear down
2021        }
2022        if !session.is_valid() {
2023            return Err("account changed during community-list sync".to_string());
2024        }
2025        let _ = crate::db::community::delete_community(&t.community_id);
2026    }
2027    let mut joined = Vec::new();
2028    for entry in list.live_entries() {
2029        let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
2030        if crate::db::community::load_community_v2(&crate::community::CommunityId(cid)).ok().flatten().is_some() {
2031            continue; // already held
2032        }
2033        if !session.is_valid() {
2034            return Err("account changed during community-list sync".to_string());
2035        }
2036        // The material IS a bundle; accept_bundle re-verifies the owner root, saves,
2037        // seeds floors, and announces our Join (idempotent for an existing member).
2038        let bundle = material_to_invite(&entry.current);
2039        if let Ok(community) = accept_bundle(transport, &session, &bundle, None).await {
2040            joined.push(community);
2041        }
2042    }
2043    Ok(joined)
2044}
2045
2046// ── Control edition authoring (CORD-04 roles / CORD-02 §6 / CORD-03 §2) ──────
2047
2048/// Publish one control edition (a role, grant, banlist, community-metadata, or
2049/// channel-metadata edit) at the next version for its entity, chaining `prev` from
2050/// our held head, and advance our local floor. Authority is enforced by every
2051/// reader's roster fold (CORD-04 §5: authority is rejection, not prevention), so this
2052/// requires only a valid local signer; a well-behaved client checks its own rank
2053/// first, but a reader drops an unauthorized edition regardless.
2054async fn publish_control_edition<T: Transport + ?Sized>(
2055    transport: &T,
2056    community: &CommunityV2,
2057    session: &SessionGuard,
2058    vsk: &str,
2059    entity_id: &[u8; 32],
2060    content: &str,
2061    citation: Option<&crate::community::edition::AuthorityCitation>,
2062) -> Result<(), String> {
2063    let me = local_keys()?;
2064    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
2065    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2066    let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
2067    let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
2068        Some((v, h)) => (v + 1, Some(h)),
2069        None => (1, None),
2070    };
2071    let at = now_ms() / 1000;
2072    let rumor = control::build_edition_rumor(me.public_key(), vsk, entity_id, version, prev.as_ref(), content, at, citation);
2073    let (wrap, _) = control::seal_control_edition(&rumor, &control, &me, Timestamp::from_secs(at)).map_err(|e| e.to_string())?;
2074    if !session.is_valid() {
2075        return Err("account changed before control publish".to_string());
2076    }
2077    transport.publish(&wrap, &community.relays).await?;
2078    // Advance our own floor so a follow-up edit chains from this head and refuse-
2079    // downgrade holds; open our own wrap to recover the self_hash + inner_id.
2080    // Re-check the session AFTER the publish await: a swap mid-publish means the
2081    // pool now points at another account's DB — skipping is safe (the next own
2082    // edit rebuilds the same head from the relay's copy).
2083    if !session.is_valid() {
2084        return Ok(());
2085    }
2086    if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
2087        crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
2088    }
2089    Ok(())
2090}
2091
2092/// Create or edit a Role (vsk 1, CORD-04 §2). `role.role_id` is the coordinate; a
2093/// rename or permission change is a versioned edit of the same id. Gated on the
2094/// reader side by `MANAGE_ROLES` + outrank.
2095pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
2096    let session = SessionGuard::capture();
2097    super::roles::validate_role(role)?;
2098    let content = super::roles::role_content_json(role)?;
2099    let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
2100    publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content, None).await
2101}
2102
2103/// Grant or revoke a member's Roles (vsk 3, CORD-04 §2). Empty `role_ids` is a
2104/// revoke. Gated on the reader side by `MANAGE_ROLES` + outrank of every role + the
2105/// member.
2106pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
2107    let session = SessionGuard::capture();
2108    let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
2109    let content = super::roles::grant_content_json(&grant)?;
2110    let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
2111    publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content, None).await
2112}
2113
2114/// The community's @admin role id: the folded Server-scope ADMIN_ALL role when one
2115/// exists, else (with `create_if_missing`) a DETERMINISTIC mint — the same id on
2116/// every device, so concurrent grants converge as editions of ONE entity instead
2117/// of forking two Admin roles.
2118pub async fn ensure_admin_role<T: Transport + ?Sized>(
2119    transport: &T,
2120    community: &CommunityV2,
2121    view: &AuthorityView,
2122    create_if_missing: bool,
2123) -> Result<Option<String>, String> {
2124    use crate::community::roles::{Permissions, Role, RoleScope};
2125    if let Some(r) = view
2126        .roles
2127        .roles
2128        .iter()
2129        .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_ALL))
2130    {
2131        return Ok(Some(r.role_id.clone()));
2132    }
2133    if !create_if_missing {
2134        return Ok(None);
2135    }
2136    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2137    let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
2138    set_role(transport, community, &Role::admin(role_id.clone())).await?;
2139    Ok(Some(role_id))
2140}
2141
2142/// Grant the @admin role (minting it deterministically when absent), MERGED into
2143/// the member's existing grant — a grant entity replaces whole (CORD-04 §2), so a
2144/// blind push would erase their other roles. Owner-only: the position-1 Admin is
2145/// manageable only by position 0 (an equal never outranks it), and refusing
2146/// before any publish keeps an unauthorized edition of the DETERMINISTIC admin
2147/// entity from advancing this device's own floor onto a head readers reject.
2148pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
2149    // Guard spans the multi-page fetch below: a swap mid-fetch must not let the
2150    // downstream publish's own (post-swap) guard write account A's floor into B.
2151    let session = SessionGuard::capture();
2152    let me = local_keys()?;
2153    if me.public_key() != community.owner()? {
2154        return Err("only the community owner can grant @admin".to_string());
2155    }
2156    let view = fetch_authority(transport, community).await;
2157    if !session.is_valid() {
2158        return Err("account changed during grant".to_string());
2159    }
2160    let member_hex = member.to_hex();
2161    require_grant_head(community, &view, &member_hex)?;
2162    let role_id = ensure_admin_role(transport, community, &view, true)
2163        .await?
2164        .expect("create_if_missing yields an id");
2165    let mut role_ids = view
2166        .roles
2167        .grants
2168        .iter()
2169        .find(|g| g.member == member_hex)
2170        .map(|g| g.role_ids.clone())
2171        .unwrap_or_default();
2172    if role_ids.contains(&role_id) {
2173        return Ok(()); // already admin — don't bump the grant edition for nothing.
2174    }
2175    role_ids.push(role_id);
2176    grant_roles(transport, community, member, role_ids).await
2177}
2178
2179/// Strip the @admin role from the member's grant, preserving their other roles.
2180/// A no-op when they don't hold it. Owner-only, like [`grant_admin`].
2181pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
2182    let session = SessionGuard::capture();
2183    let me = local_keys()?;
2184    if me.public_key() != community.owner()? {
2185        return Err("only the community owner can revoke @admin".to_string());
2186    }
2187    let view = fetch_authority(transport, community).await;
2188    if !session.is_valid() {
2189        return Err("account changed during revoke".to_string());
2190    }
2191    let member_hex = member.to_hex();
2192    require_grant_head(community, &view, &member_hex)?;
2193    let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
2194        return Ok(()); // no admin role exists — nothing to revoke.
2195    };
2196    let mut role_ids = view
2197        .roles
2198        .grants
2199        .iter()
2200        .find(|g| g.member == member_hex)
2201        .map(|g| g.role_ids.clone())
2202        .unwrap_or_default();
2203    let before = role_ids.len();
2204    role_ids.retain(|r| r != &role_id);
2205    if role_ids.len() == before {
2206        return Ok(());
2207    }
2208    grant_roles(transport, community, member, role_ids).await
2209}
2210
2211/// A grant replaces whole — refuse the merge when this member's grant is FLOORED
2212/// locally but no head folded (withheld / evicted): a blind push at that point
2213/// would erase their other roles at a higher version.
2214fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
2215    let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
2216        return Err("malformed member key".to_string());
2217    };
2218    let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
2219    if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
2220        return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
2221    }
2222    Ok(())
2223}
2224
2225/// Replace the Banlist (vsk 4, CORD-04 §4) with `banned` (lowercase-hex npubs), the
2226/// whole list on every edit. Gated on the reader side by `BAN`.
2227pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
2228    let session = SessionGuard::capture();
2229    super::roles::validate_banlist(banned)?;
2230    let content = super::roles::banlist_content_json(banned)?;
2231    let eid = super::derive::banlist_locator(community.id());
2232    publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content, None).await
2233}
2234
2235/// Edit the community metadata (vsk 0, CORD-02 §6). Gated on the reader side by
2236/// `MANAGE_METADATA`.
2237pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
2238    let session = SessionGuard::capture();
2239    control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
2240    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
2241    publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content, None).await
2242}
2243
2244/// Add or edit a channel's metadata (vsk 2, CORD-03 §2). `channel_id` is the
2245/// coordinate. Gated on the reader side by `MANAGE_CHANNELS`.
2246pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
2247    let session = SessionGuard::capture();
2248    let me = local_keys()?;
2249    ensure_channel_manager(community, &me.public_key())?;
2250    // Public → private CONVERSION is a key rotation (CORD-03 §2) this build doesn't
2251    // mint yet — refuse the flag flip rather than publish an edition no reader can
2252    // key (members would keep posting on the root-derived plane, splitting the
2253    // channel). Private → public works (readers heal to the root derivation).
2254    if meta.private {
2255        if let Some(held) = community.channel(channel_id) {
2256            if !held.private {
2257                return Err("converting a public channel to private is not supported yet".to_string());
2258            }
2259        }
2260    }
2261    control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
2262    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
2263    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await
2264}
2265
2266/// The local mirror of the reader's `MANAGE_CHANNELS` fold gate (CORD-03 §2): the
2267/// owner, or a roster-authorized manager who isn't banned. Refusing BEFORE any
2268/// publish keeps an unauthorized device from advancing its own edition floor onto
2269/// a head every reader rejects (wedging its later, legitimately-authorized edits
2270/// behind a rejected chain).
2271fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
2272    let owner = community.owner()?;
2273    if *me == owner {
2274        return Ok(());
2275    }
2276    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2277    let me_hex = me.to_hex();
2278    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
2279        return Err("you are banned from this community".to_string());
2280    }
2281    let roster = crate::db::community::get_community_roles(&cid_hex)?;
2282    if roster.is_authorized(&me_hex, Some(&owner.to_hex()), crate::community::roles::Permissions::MANAGE_CHANNELS) {
2283        Ok(())
2284    } else {
2285        Err("managing channels here needs the MANAGE_CHANNELS permission".to_string())
2286    }
2287}
2288
2289/// Create a new PUBLIC channel (CORD-03 §2): mint a fresh id, publish its metadata
2290/// edition (vsk 2), and add it to the held community. A Public channel derives its Chat
2291/// Plane from the `community_root` (no per-channel key), so other members fold it in on
2292/// their next control follow with nothing to distribute. Returns the new channel id.
2293/// Reader-gated by `MANAGE_CHANNELS`.
2294pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
2295    let session = SessionGuard::capture();
2296    // Serialize with the follow worker: the save below writes the WHOLE community
2297    // row from this caller's struct, so an unserialized concurrent follow adopting
2298    // a rotation would be rolled back to a stale root (a deaf community).
2299    let lock = super::realtime::follow_lock(community.id());
2300    let _guard = lock.lock().await;
2301    let me = local_keys()?;
2302    ensure_channel_manager(community, &me.public_key())?;
2303    let channel_id = ChannelId(super::super::random_32());
2304    let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
2305    control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
2306    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2307    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2308    if !session.is_valid() {
2309        return Err("account changed during channel create".to_string());
2310    }
2311    // Add locally + persist so the creator can post immediately (peers fold it in).
2312    let mut updated = community.clone();
2313    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() });
2314    crate::db::community::save_community_v2(&updated)?;
2315    Ok(channel_id)
2316}
2317
2318/// Create a new PRIVATE channel (CORD-03 §2): mint a fresh id + an independent
2319/// random key at channel-epoch 1, deliver the key to every current member over the
2320/// rekey plane (CORD-06 §1), then announce the channel (vsk 2, `private`). Epoch 0
2321/// is the root generation ("the first privatisation is epoch 1"), so the delivery
2322/// commits its continuity to `(0, community_root)` — verifiable by every member and
2323/// bound to THIS community's root. The key ships BEFORE the announcement: an
2324/// aborted attempt leaves only an unannounced crate (invisible), and a retry mints
2325/// a fresh id, so there is no same-coordinate double-mint to fork on. Live public
2326/// links are refreshed so a joiner's bundle carries the key; a member who joins
2327/// through the stale-bundle window keys up at the channel's next rotation.
2328pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
2329    let session = SessionGuard::capture();
2330    // Serialize with the follow worker across the whole fetch→publish→save span
2331    // (the memberlist fetch is seconds long; an unserialized follow adopting a
2332    // rotation meanwhile would be rolled back by the whole-row save below).
2333    let lock = super::realtime::follow_lock(community.id());
2334    let _guard = lock.lock().await;
2335    let me = local_keys()?;
2336    ensure_channel_manager(community, &me.public_key())?;
2337    let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
2338    control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
2339    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2340
2341    let channel_id = ChannelId(super::super::random_32());
2342    let channel_key = super::super::random_32();
2343    let epoch = Epoch(1);
2344
2345    // Recipients: every current member, plus me (multi-device).
2346    let mut recipients = memberlist(transport, community).await?;
2347    if !recipients.iter().any(|p| *p == me.public_key()) {
2348        recipients.push(me.public_key());
2349    }
2350    let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
2351    let mut blobs = Vec::with_capacity(recipients.len());
2352    for r in &recipients {
2353        blobs.push(
2354            rekey::build_blob_local(me.secret_key(), &me.public_key().to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
2355                .map_err(|e| e.to_string())?,
2356        );
2357    }
2358    let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
2359    let at_secs = now_ms() / 1000;
2360    let chunks = rekey::build_rekey_chunks_local(&me, &group, RekeyScope::Channel(channel_id), epoch, Epoch(0), &prev_commit, &blobs, at_secs)
2361        .map_err(|e| e.to_string())?;
2362    if !session.is_valid() {
2363        return Err("account changed during channel create".to_string());
2364    }
2365    for c in &chunks {
2366        transport.publish_durable(c, &community.relays).await?;
2367    }
2368    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2369    if !session.is_valid() {
2370        return Err("account changed during channel create".to_string());
2371    }
2372    // A leave/delete raced the create: saving would resurrect the community row.
2373    if crate::db::community::community_protocol(community.id())?.is_none() {
2374        return Err("community removed during channel create".to_string());
2375    }
2376    let mut updated = community.clone();
2377    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() });
2378    crate::db::community::save_community_v2(&updated)?;
2379    // Archive the epoch-1 key so this channel's history stays readable across its
2380    // future rotations (CORD-03 §3).
2381    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2382    crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
2383    // Future joiners are handed the key in their (refreshed) bundle, CORD-05 §1.
2384    let _ = refresh_public_links(transport, &updated).await;
2385    Ok(channel_id)
2386}
2387
2388/// Tombstone a channel (CORD-03 §2, `deleted: true`) + drop it locally. Reader-gated by
2389/// `MANAGE_CHANNELS`; the coordinate stays folded as a grave so peers hide it.
2390pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
2391    let session = SessionGuard::capture();
2392    // Whole-row save below — serialize with the follow worker (see create_*_channel).
2393    let lock = super::realtime::follow_lock(community.id());
2394    let _guard = lock.lock().await;
2395    let me = local_keys()?;
2396    ensure_channel_manager(community, &me.public_key())?;
2397    // The tombstone carries the FULL held document (deleted flag set): a strict
2398    // reader treats an edition as the entity, so even a deletion must not strip
2399    // fields it didn't touch (CORD-02 §6).
2400    let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
2401        name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
2402    });
2403    meta.deleted = Some(true);
2404    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2405    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2406    if !session.is_valid() {
2407        return Err("account changed during channel delete".to_string());
2408    }
2409    let mut updated = community.clone();
2410    updated.channels.retain(|c| c.id.0 != channel_id.0);
2411    crate::db::community::save_community_v2(&updated)?;
2412    Ok(())
2413}
2414
2415// ── Live control-follow (CORD-02 §6 / CORD-03 §2) ────────────────────────────
2416
2417/// Re-fold this community's Control Plane and apply the current metadata +
2418/// **public** channel set to the held community, persisting any change. Called
2419/// when a control-plane wrap arrives in realtime (a rename, a new channel, an
2420/// edited description) so a long-running bot tracks the community mid-session
2421/// instead of freezing at its join-time view.
2422///
2423/// **Authority (CORD-04 §5):** the roster (roles/grants/banlist) folds first into
2424/// the owner-seeded authorized set ([`fold_authority`]), then each metadata/channel
2425/// edition is eligible only if its signer CURRENTLY holds the entity's management
2426/// bit (`MANAGE_METADATA`/`MANAGE_CHANNELS`) — so an authorized admin's edits fold,
2427/// a demoted one's drop. The owner is supreme, proven by the self-certifying
2428/// community_id (no network trust).
2429///
2430/// **Private channels are skipped here:** a Private channel's Chat-Plane key is
2431/// delivered over the rekey plane (or an invite bundle), never derivable from a
2432/// control edition alone. A new Private channel therefore surfaces only once
2433/// [`follow_rekeys`] delivers its key. Public channels derive from the
2434/// community_root, so they fold in directly.
2435///
2436/// Returns the updated community iff something changed (so the caller can skip a
2437/// redundant re-subscribe + refresh notification).
2438pub async fn follow_control<T: Transport + ?Sized>(
2439    transport: &T,
2440    community: &CommunityV2,
2441    session: &SessionGuard,
2442) -> Result<Option<CommunityV2>, String> {
2443    community.owner()?; // fail fast if the community is somehow unproven.
2444    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
2445    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2446
2447    // Per-entity refuse-downgrade floors for the CURRENT epoch only. A head recorded
2448    // under a prior epoch is excluded, so that entity auto-bootstraps after a
2449    // Refounding (Armada accepts a compacted head across a dangling prev — matched).
2450    // A read error FAILS CLOSED: an empty map would silently re-open the rollback
2451    // window the floor exists to shut.
2452    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2453        .into_iter()
2454        .filter(|(_, f)| f.0 == community.root_epoch.0)
2455        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2456        .collect();
2457
2458    // Newest window first; page OLDER only while a tracking entity is gapped (its
2459    // floor link evicted from the window — H1/M8 refetch), bounded like the join
2460    // verifier. A withholding relay still converges to fail-closed after the cap.
2461    let mut editions: Vec<ParsedEdition> = Vec::new();
2462    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2463    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
2464    let mut oldest: Option<u64> = None;
2465    let mut until: Option<u64> = None;
2466    let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false };
2467    let mut authority = AuthoritySet::owner_only();
2468    for _ in 0..FOLLOW_MAX_PAGES {
2469        let query = Query {
2470            kinds: vec![stream::KIND_WRAP],
2471            authors: vec![control.pk_hex()],
2472            until,
2473            limit: Some(FOLLOW_PAGE),
2474            ..Default::default()
2475        };
2476        let wraps = transport.fetch(&query, &community.relays).await?;
2477        // The `until` cursor is INCLUSIVE (a `-1` step can skip same-second siblings
2478        // at a page boundary); the wrap-id dedup makes re-served boundary events
2479        // free, and a page with nothing new means the relay is exhausted.
2480        let mut fresh = 0usize;
2481        for w in &wraps {
2482            if !seen_wraps.insert(w.id) {
2483                continue;
2484            }
2485            fresh += 1;
2486            let at = w.created_at.as_secs();
2487            if oldest.is_none_or(|o| at < o) {
2488                oldest = Some(at);
2489            }
2490            // Open + seal-verify every edition; authority is resolved by the roster
2491            // fold (CORD-04 §5), not by a signer filter here — an admin's edits fold.
2492            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
2493                if seen.insert(ed.inner_id) {
2494                    editions.push(ed);
2495                }
2496            }
2497        }
2498        // Roster first (roles/grants/banlist → authorized set), then the authority-
2499        // gated metadata/channel fold over the same edition set.
2500        authority = fold_authority(community, &editions, &floors);
2501        fold = apply_control_fold(community, &editions, &floors, &authority);
2502        if !(fold.gapped || authority.gapped) || fresh == 0 {
2503            break;
2504        }
2505        until = oldest;
2506    }
2507
2508    // The fetches straddled awaits; a swap since the guard was captured must not
2509    // write account A's control state into B.
2510    if !session.is_valid() {
2511        return Err("account changed during control follow".to_string());
2512    }
2513    // A leave/delete raced this follow: writing now would resurrect the community
2514    // row and orphan floor rows past delete_community's wipe.
2515    if crate::db::community::community_protocol(community.id())?.is_none() {
2516        return Ok(None);
2517    }
2518    // Persist advanced floors BEFORE the state save (a failed floor write must not
2519    // let saved state outrun its floor), stamping the epoch this fold ran under —
2520    // not the row's write-time value, which a concurrent re-founding can bump. Both
2521    // the metadata/channel heads and the roster/banlist heads advance their floors;
2522    // run the advance (v+1) and same-version convergence (fork tiebreak) paths.
2523    for h in fold.heads.iter().chain(authority.heads.iter()) {
2524        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)?;
2525        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)?;
2526    }
2527    // Persist the authorized banlist content (retained/withholding folds carry None,
2528    // so the stored banlist is left intact — an anti-roster never silently un-bans).
2529    if let Some((banned, version)) = &authority.banlist_persist {
2530        crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
2531    }
2532    // Persist the authorized roster so capabilities/roles stay sync LOCAL reads
2533    // (v1 parity: the passive follow folds, reads never fetch). Guarded like v1's
2534    // fetch path: only an aggregate built from roster editions at least as new as
2535    // the stored one may replace it — a withholding relay serving NO roster
2536    // editions folds an empty-but-ungapped aggregate (absence raises no gap flag),
2537    // and that must RETAIN the stored roster, never wipe standing.
2538    let newest_roster_at: i64 = editions
2539        .iter()
2540        .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
2541        .map(|e| e.created_at as i64)
2542        .max()
2543        .unwrap_or(0);
2544    // Completeness gate: the `gapped` flag only covers entities present in the window.
2545    // A role/grant floored on this device but with ZERO editions fetched (aged out of
2546    // the paging reach) folds absent yet raises no gap — persisting would silently drop
2547    // it. So if any CURRENTLY-STORED entity is floored but folded no head this round,
2548    // RETAIN. A real revoke still folds a head (see select_authorized), so it persists.
2549    let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2550    let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
2551    let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
2552        && stored.grants.iter().all(|g| {
2553            crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
2554                let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
2555                !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
2556            })
2557        });
2558    if !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
2559        crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
2560    }
2561    match fold.updated {
2562        Some(u) => {
2563            crate::db::community::save_community_v2(&u)?;
2564            Ok(Some(u))
2565        }
2566        None => Ok(None),
2567    }
2568}
2569
2570/// Control-follow paging bounds: enough depth to re-anchor a long-offline floor
2571/// (H1/M8 refetch) without letting a flooding relay stall the follow queue.
2572const FOLLOW_MAX_PAGES: usize = 4;
2573const FOLLOW_PAGE: usize = 500;
2574
2575/// A folded control head to persist as the per-entity refuse-downgrade floor.
2576#[derive(Clone)]
2577struct FoldedHead {
2578    entity_hex: String,
2579    version: u64,
2580    self_hash: [u8; 32],
2581    inner_id: [u8; 32],
2582}
2583
2584/// The outcome of a floor-aware control fold: the updated community (if content
2585/// changed), the heads to persist as the new floor (returned even when content is
2586/// unchanged, so the floor still seeds/advances), and whether any TRACKING entity
2587/// hit an unresolvable gap — the caller's signal to page older history and re-fold
2588/// (CORD-04 H1/M8's refetch).
2589struct ControlFold {
2590    updated: Option<CommunityV2>,
2591    heads: Vec<FoldedHead>,
2592    gapped: bool,
2593}
2594
2595/// Per-entity floor: `(version, self_hash, inner_id)` of the committed head.
2596type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
2597
2598/// Fold owner-authored control editions into an updated community using the
2599/// PERSISTED per-entity version floor (refuse-downgrade). Per entity, fold with
2600/// [`version::fold`]`(floor, floor_hash)`:
2601///   - ANCHORED: adopt the chain-verified head. A `gap` ABOVE it (withheld middles)
2602///     doesn't block the verified prefix — refuse-downgrade holds for everything
2603///     applied — but flags `gapped` so the caller pages for the rest.
2604///   - UNANCHORED under a held floor: one legitimate cause is a same-version owner
2605///     fork AT the floor whose deterministic winner (lower inner id; a NULL held id
2606///     is always replaceable, mirroring v1's `decide()`) isn't our held edition —
2607///     the floor CONVERGES to the winner and the chain re-anchors on it, so every
2608///     client lands on the same head where a hash-strict floor would wedge forever.
2609///     Anything else is withholding → fail closed + `gapped`.
2610///   - BOOTSTRAPPING (`floor == 0` — a fresh joiner, or a fresh epoch after a
2611///     Refounding, since the caller epoch-filters the floor) takes the highest
2612///     signed head (author already owner-filtered).
2613/// This matches CORD-04 §1 and mirrors v1's `fold_roster`. Epoch-filtering makes a
2614/// compaction at a new epoch auto-bootstrap, converging with Armada's acceptance of
2615/// a compacted head across a dangling `prev` (Armada doesn't persist a floor, so a
2616/// Vector floor only makes Vector STRICTER locally — no wire change, honest-case
2617/// convergence preserved).
2618fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
2619    use crate::community::roles::Permissions;
2620    use std::collections::BTreeMap;
2621
2622    let owner_hex = community.owner().ok().map(|o| o.to_hex());
2623
2624    let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
2625    for e in editions {
2626        groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
2627    }
2628
2629    let mut out = community.clone();
2630    let mut changed = false;
2631    let mut heads = Vec::new();
2632    let mut gapped = false;
2633    for ((vsk_code, eid), group) in &groups {
2634        // This fold applies exactly two entities: community metadata (eid ==
2635        // community_id) and channel metadata. A vsk-2 whose eid equals the community
2636        // id is excluded — the floor row keys on the entity alone, so it would share
2637        // (and corrupt) the metadata chain's floor.
2638        let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
2639        let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
2640        if !is_meta && !is_channel {
2641            continue;
2642        }
2643        // Authority gate (CORD-04 §5): only editions whose author CURRENTLY holds the
2644        // entity's management bit are eligible. Pre-filtering before the fold means a
2645        // demoted admin's (possibly higher-version) edition can't be the head; the
2646        // highest AUTHORIZED head wins. The owner is supreme.
2647        let required = if is_meta { Permissions::MANAGE_METADATA } else { Permissions::MANAGE_CHANNELS };
2648        let authed: Vec<&ParsedEdition> = group
2649            .iter()
2650            .copied()
2651            .filter(|e| {
2652                let author = e.author.to_hex();
2653                // A banned npub's edits are dropped (CORD-04 §4), even if they still
2654                // held a bit via a not-yet-stripped grant.
2655                !authority.banned.contains(&author) && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
2656            })
2657            .collect();
2658        if authed.is_empty() {
2659            continue;
2660        }
2661        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
2662        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
2663        let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
2664        gapped |= entity_gapped;
2665        let Some(hi) = hi else { continue };
2666
2667        let head = authed[hi];
2668        heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
2669        if is_meta {
2670            if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
2671                changed |= apply_community_metadata(&mut out, meta);
2672            }
2673        } else if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
2674            // vsk-2 carries no community binding (shared v1 grammar); a same-owner
2675            // cross-community replay can inject a phantom PUBLIC channel (bounded:
2676            // root-scoped key, eids don't collide). Binding is a deferred wire change.
2677            changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
2678        }
2679    }
2680    ControlFold { updated: changed.then_some(out), heads, gapped }
2681}
2682
2683/// Fold one entity's editions against its persisted floor into a head index (into the
2684/// input slice) plus whether a TRACKING gap was hit (the caller pages older history).
2685/// Encapsulates the W2 refuse-downgrade policy: bootstrap at floor 0 (highest signed
2686/// head, what Armada shows across a compaction's dangling prev); adopt the chain-
2687/// anchored head, paging on an upper gap; converge a same-version fork at the floor to
2688/// the lower-inner-id winner; and fail closed otherwise.
2689fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
2690    let floor_v = floor.map(|f| f.0).unwrap_or(0);
2691    if floor_v == 0 {
2692        return (version::bootstrap_head(fold_eds, 0), false);
2693    }
2694    let floor_hash = floor.map(|f| &f.1);
2695    let held_inner = floor.and_then(|f| f.2);
2696    let result = version::fold(fold_eds, floor_v, floor_hash);
2697    if result.anchored {
2698        return (result.head, result.gap); // verified prefix; page any upper gap.
2699    }
2700    if result.head.is_none() && !result.gap {
2701        return (None, false); // everything below floor — a stale relay, no paging.
2702    }
2703    // Unanchored under a held floor: converge a same-version fork at the floor to its
2704    // deterministic winner (lower inner id; a NULL held id is always replaceable),
2705    // else fail closed.
2706    let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
2707    let win_hash = match fork {
2708        Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
2709        _ => return (None, true), // detached from our committed head → withholding.
2710    };
2711    let re = version::fold(fold_eds, floor_v, Some(&win_hash));
2712    if !re.anchored {
2713        return (None, true);
2714    }
2715    (re.head, re.gap)
2716}
2717
2718/// The folded, delegation-AUTHORIZED control-plane authority (CORD-04): the roster
2719/// (roles + grants, owner-seeded fixpoint), the enforced banlist, and the
2720/// role/grant/banlist heads to persist as refuse-downgrade floors. The owner is
2721/// recomputed from the self-certifying community_id at each use.
2722struct AuthoritySet {
2723    roles: crate::community::roles::CommunityRoles,
2724    banned: std::collections::BTreeSet<String>,
2725    heads: Vec<FoldedHead>,
2726    gapped: bool,
2727    /// The authorized banlist `(content, version)` to persist when an authorized head
2728    /// advanced the floor. `None` when the banlist was retained (no new authorized
2729    /// head) or is empty — the caller then leaves the stored banlist untouched.
2730    banlist_persist: Option<(Vec<String>, u64)>,
2731}
2732
2733impl AuthoritySet {
2734    /// Bootstrap authority for a community with no roster editions folded yet: only
2735    /// the owner is authorized (supreme), nobody banned.
2736    fn owner_only() -> Self {
2737        AuthoritySet { roles: Default::default(), banned: Default::default(), heads: vec![], gapped: false, banlist_persist: None }
2738    }
2739}
2740
2741/// Fold the roster/banlist entities (vsk 1/3/4) from the control editions into the
2742/// delegation-AUTHORIZED roster + enforced banlist (CORD-04 §2-§5). Each entity binds
2743/// to its coordinate (role at role_id, grant at grant_locator(cid, member), banlist at
2744/// banlist_locator(cid)); a content whose coordinate doesn't match is dropped. Roles
2745/// cap at the 100 lowest role_ids, a member at 64 roles, the banlist at 500. The
2746/// banlist is enforced only if its head's signer held BAN in the authorized roster.
2747fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
2748    use crate::community::roles::Permissions;
2749    use std::collections::BTreeMap;
2750
2751    let cid = community.id();
2752    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2753    let owner = community.owner().ok();
2754    let owner_hex = owner.map(|o| o.to_hex());
2755    let banlist_eid = super::derive::banlist_locator(cid);
2756    let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
2757
2758    let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
2759    for e in editions {
2760        if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
2761            groups.entry(e.entity_id).or_default().push(e);
2762        }
2763    }
2764
2765    // Per-entity CANDIDATE lists — every ≥floor edition of a role/grant, highest
2766    // version first (lowest inner-id as the deterministic tiebreak). CORD-04 §1: an
2767    // edition whose signer isn't authorized is SIMPLY DROPPED and the fold continues
2768    // to the next candidate, so a forged higher-version edition can't suppress the
2769    // authorized head beneath it (the author-blind collapse-to-one-head it replaces
2770    // let any member vanish a role or a member's grant). `gapped` (drives older-
2771    // paging) stays fold_head's per-entity flag.
2772    let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
2773    let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
2774    let mut gapped = false;
2775
2776    for (eid, group) in &groups {
2777        // The banlist is folded author-aware AFTER the roster is known (below).
2778        if *eid == banlist_eid {
2779            continue;
2780        }
2781        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
2782        let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
2783        let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
2784        gapped |= entity_gapped;
2785        let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
2786
2787        for p in group {
2788            // Refuse-downgrade: never consider an edition below the persisted floor.
2789            if p.version < floor_v {
2790                continue;
2791            }
2792            let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
2793            match p.vsk.as_str() {
2794                vsk::ROLE => {
2795                    // Bind: the content's role_id IS the coordinate; position 0 is the owner's.
2796                    if let Some(role) = super::roles::parse_role_content(&p.content) {
2797                        if role.role_id == entity_hex && role.position != 0 {
2798                            role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head });
2799                        }
2800                    }
2801                }
2802                vsk::GRANT => {
2803                    if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
2804                        if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
2805                            if super::derive::grant_locator(cid, &member) == *eid {
2806                                grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
2807                                grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head });
2808                            }
2809                        }
2810                    }
2811                }
2812                _ => {}
2813            }
2814        }
2815    }
2816    for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
2817        cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
2818    }
2819
2820    let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2821    // Preliminary roster (bans not yet applied) — the authority view the banlist head
2822    // is judged against.
2823    let (prelim, _) = select_authorized(&role_cands, &grant_cands, owner_hex.as_deref(), &empty);
2824
2825    // Banlist (CORD-04 §4), folded AUTHORITY-aware so its two anti-roster hazards are
2826    // both closed:
2827    //   - head selection: the head is the highest version whose author CURRENTLY holds
2828    //     BAN — an unauthorized higher-version edition can't erase existing bans
2829    //     (fail-open), and the floor never advances to one;
2830    //   - per-target: each entry is kept only if the author STRICTLY OUTRANKS that
2831    //     target (`can_act_on_member` — an admin can't ban a peer/superior, and the
2832    //     owner is unbannable);
2833    //   - withholding: when no authorized head is served, the persisted banlist is
2834    //     RETAINED (an anti-roster must not un-ban on a relay withholding the ban).
2835    let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2836    // An ALREADY-banned npub can't author the banlist (a banned member vanishes, §4), or
2837    // a BAN-holder whose grant-strip hasn't yet folded could publish a list omitting their
2838    // OWN ban to un-ban themselves (removals aren't outrank-checked). Exclude them from
2839    // head eligibility, not just from the roster.
2840    let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
2841    let banlist_authored: Vec<&ParsedEdition> = groups
2842        .get(&banlist_eid)
2843        .map(|g| {
2844            g.iter()
2845                .copied()
2846                .filter(|e| {
2847                    let ah = e.author.to_hex();
2848                    !banned_authors.contains(ah.as_str()) && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
2849                })
2850                .collect()
2851        })
2852        .unwrap_or_default();
2853    let mut banlist_persist: Option<(Vec<String>, u64)> = None;
2854    let mut banlist_head: Option<FoldedHead> = None;
2855    let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
2856        persisted_banned.into_iter().collect()
2857    } else {
2858        let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
2859        let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
2860        gapped |= g;
2861        match hi {
2862            Some(hi) => {
2863                let head = banlist_authored[hi];
2864                let ah = head.author.to_hex();
2865                let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
2866                    .unwrap_or_default()
2867                    .into_iter()
2868                    .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
2869                    .take(super::roles::MAX_BANLIST)
2870                    .collect();
2871                banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
2872                banlist_persist = Some((list.clone(), head.version));
2873                list.into_iter().collect()
2874            }
2875            None => persisted_banned.into_iter().collect(),
2876        }
2877    };
2878
2879    // Final roster (CORD-04 §4: a banned npub vanishes — every edition it authored is
2880    // dropped, and a grant TO a banned member carries no rank). Re-run selection with
2881    // the banned set excluded so a banned admin loses authority.
2882    let (mut authorized, mut heads) = select_authorized(&role_cands, &grant_cands, owner_hex.as_deref(), &banned);
2883    if let Some(bh) = banlist_head {
2884        heads.push(bh);
2885    }
2886
2887    // Cap the AUTHORIZED community at the 100 lowest role_ids — applied AFTER
2888    // authorization, so an attacker's unauthorized roles can't consume cap slots and
2889    // evict a legitimate one (the pre-authorize cap they replace let 100 forged low-id
2890    // roles empty the roster).
2891    if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
2892        authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
2893        authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
2894        let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
2895        authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
2896        authorized.grants.retain(|g| !g.role_ids.is_empty());
2897    }
2898
2899    AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist }
2900}
2901
2902/// One candidate edition of a role/grant entity — the pool [`select_authorized`]
2903/// draws the highest AUTHORIZED head from (exactly one of `role`/`grant` is set).
2904struct AuthorityCand {
2905    role: Option<crate::community::roles::Role>,
2906    grant: Option<crate::community::roles::MemberGrant>,
2907    author: PublicKey,
2908    head: FoldedHead,
2909}
2910
2911/// The owner-seeded delegation fixpoint (CORD-04 §1/§2), author-AWARE: per entity it
2912/// takes the highest-version candidate whose author is authorized to author it under
2913/// the roster resolved SO FAR, dropping unauthorized higher versions rather than
2914/// vanishing the entity. Authority resolves outward from the owner (proven by
2915/// `community_id`, never a Role), and the strict-outrank rule (no edition at/above its
2916/// signer's own position) keeps the fixpoint monotone, so it converges. Returns the
2917/// authorized roster plus the per-entity heads of the SELECTED editions (the floor
2918/// advances only to authorized heads — an unauthorized forgery never poisons it).
2919fn select_authorized(
2920    role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
2921    grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
2922    owner_hex: Option<&str>,
2923    excluded: &std::collections::BTreeSet<String>,
2924) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
2925    use crate::community::roles::{CommunityRoles, Permissions};
2926    let mut accepted = CommunityRoles::default();
2927    let mut heads: Vec<FoldedHead> = Vec::new();
2928    // Jacobi iteration: authority propagates one delegation level per round, so a
2929    // generous multiple of the entity count is an ample bound. Non-convergence (never
2930    // seen for an owner-rooted chain) falls through fail-safe: only authorized editions
2931    // are ever selected.
2932    let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
2933    for _ in 0..bound {
2934        let mut next = CommunityRoles::default();
2935        let mut next_heads: Vec<FoldedHead> = Vec::new();
2936
2937        for cands in role_cands.values() {
2938            for c in cands {
2939                let Some(role) = &c.role else { continue };
2940                let ah = c.author.to_hex();
2941                if excluded.contains(&ah) {
2942                    continue;
2943                }
2944                if role.position != 0 && accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
2945                    next.roles.push(role.clone());
2946                    next_heads.push(c.head.clone());
2947                    break; // highest authorized candidate for this entity
2948                }
2949            }
2950        }
2951        for cands in grant_cands.values() {
2952            for c in cands {
2953                let Some(grant) = &c.grant else { continue };
2954                let ah = c.author.to_hex();
2955                if excluded.contains(&ah) || excluded.contains(&grant.member) {
2956                    continue;
2957                }
2958                // The granter must outrank every granted role (resolved against the
2959                // accepted roster) AND the member — the escalation defense (CORD-04 §2).
2960                let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
2961                let Some(positions) = positions else { continue };
2962                if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
2963                    && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
2964                {
2965                    // Record the head even for an EMPTY grant (a revoke is a real chain
2966                    // advance a completeness check must see), but don't carry the husk
2967                    // into the roster.
2968                    next_heads.push(c.head.clone());
2969                    if !grant.role_ids.is_empty() {
2970                        next.grants.push(grant.clone());
2971                    }
2972                    break;
2973                }
2974            }
2975        }
2976
2977        let converged = next.roles == accepted.roles && next.grants == accepted.grants;
2978        accepted = next;
2979        heads = next_heads;
2980        if converged {
2981            break;
2982        }
2983    }
2984    (accepted, heads)
2985}
2986
2987/// Apply a folded community-metadata head. Relays only overwrite when the edition
2988/// carries a non-empty list (a metadata edition that omits relays must not blank
2989/// the working set). Returns whether anything changed.
2990fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
2991    let mut changed = false;
2992    if out.name != meta.name {
2993        out.name = meta.name;
2994        changed = true;
2995    }
2996    if out.description != meta.description {
2997        out.description = meta.description;
2998        changed = true;
2999    }
3000    // Icon/banner apply verbatim, None included — an edition is the full
3001    // document, so an absent image IS a removal (editors preserve via
3002    // `CommunityV2::metadata()`).
3003    if out.icon != meta.icon {
3004        out.icon = meta.icon;
3005        changed = true;
3006    }
3007    if out.banner != meta.banner {
3008        out.banner = meta.banner;
3009        changed = true;
3010    }
3011    // Client-extensible + unknown fields ride the fold verbatim so our own
3012    // editions can carry them forward (CORD-02 §6).
3013    if out.meta_custom != meta.custom {
3014        out.meta_custom = meta.custom;
3015        changed = true;
3016    }
3017    if out.meta_extra != meta.extra {
3018        out.meta_extra = meta.extra;
3019        changed = true;
3020    }
3021    if !meta.relays.is_empty() && out.relays != meta.relays {
3022        out.relays = meta.relays;
3023        changed = true;
3024    }
3025    changed
3026}
3027
3028/// Apply a folded channel-metadata head: delete removes the channel, a rename
3029/// updates an existing one, a brand-new PUBLIC channel is added, and a brand-new
3030/// PRIVATE one is recorded KEYLESS (unreadable until its key arrives over the
3031/// rekey plane or a fresh bundle). Returns whether anything changed.
3032fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
3033    let deleted = meta.deleted.unwrap_or(false);
3034    if deleted {
3035        let before = out.channels.len();
3036        out.channels.retain(|c| c.id.0 != id.0);
3037        return out.channels.len() != before;
3038    }
3039    match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
3040        Some(existing) => {
3041            let mut changed = false;
3042            if existing.name != meta.name {
3043                existing.name = meta.name;
3044                changed = true;
3045            }
3046            // vsk-2 fields Vector doesn't drive still fold + persist, so a later
3047            // local edit republishes them instead of wiping (CORD-02 §6).
3048            if existing.voice != meta.voice {
3049                existing.voice = meta.voice;
3050                changed = true;
3051            }
3052            if existing.meta_custom != meta.custom {
3053                existing.meta_custom = meta.custom;
3054                changed = true;
3055            }
3056            if existing.meta_extra != meta.extra {
3057                existing.meta_extra = meta.extra;
3058                changed = true;
3059            }
3060            // The owner's edition authoritatively declares visibility. A channel the
3061            // owner marks PUBLIC must derive from the root (key = None) — this heals a
3062            // bundle-time misclassification where an attacker set a public channel's
3063            // grant key to their own, silently addressing it at a plane only they read.
3064            // Public → private CONVERSION is DEFERRED: the flip is IGNORED here (the
3065            // record stays public) until the convert flow (key mint + cursor rebase
3066            // to the conversion's channel epoch) lands — the send side refuses to
3067            // publish one, and a foreign client's conversion won't move us.
3068            if !meta.private && (existing.private || existing.key.is_some()) {
3069                existing.private = false;
3070                existing.key = None;
3071                changed = true;
3072            }
3073            changed
3074        }
3075        None if !meta.private => {
3076            // A public channel derives its Chat Plane from the community_root at the
3077            // current root epoch (key = None); its stored epoch mirrors the root.
3078            out.channels.push(ChannelV2 {
3079                id,
3080                name: meta.name,
3081                private: false,
3082                key: None,
3083                epoch: out.root_epoch,
3084                voice: meta.voice,
3085                meta_custom: meta.custom,
3086                meta_extra: meta.extra,
3087            });
3088            true
3089        }
3090        None => {
3091            // A brand-new PRIVATE channel: record it KEYLESS at epoch 0 (the root
3092            // generation — CORD-03 §2 numbers the first private key epoch 1). The
3093            // epoch then doubles as [`follow_rekeys`]' scan cursor. Until a rotation
3094            // delivers a key, every read/send/subscribe path skips the channel; the
3095            // root-fallback in `channel_secret` is never taken for it.
3096            out.channels.push(ChannelV2 {
3097                id,
3098                name: meta.name,
3099                private: true,
3100                key: None,
3101                epoch: Epoch(0),
3102                voice: meta.voice,
3103                meta_custom: meta.custom,
3104                meta_extra: meta.extra,
3105            });
3106            true
3107        }
3108    }
3109}
3110
3111// ── Live rekey-follow (CORD-06 §2/§3) ────────────────────────────────────────
3112
3113/// The outcome of a rekey-follow pass.
3114pub struct RekeyFollow {
3115    /// The community after adopting every rotation it could catch up on, or `None`
3116    /// if nothing advanced.
3117    pub updated: Option<CommunityV2>,
3118    /// A base rotation removed us — the caller tears the local hold down (the
3119    /// updated community is not persisted in that case).
3120    pub self_removed: bool,
3121    /// An owner tombstone sits on the dissolved plane (CORD-02 §9) — the local
3122    /// flag is already set; the caller surfaces the death and stops following.
3123    pub dissolved: bool,
3124}
3125
3126/// The most archived base roots a channel-rekey lookup fans across per step. A
3127/// standalone rekey rides the minter's then-current root and a removal's rides the
3128/// PRIOR root (CORD-06 §3), so a follower whose base already advanced must look
3129/// back. A channel stranded DEEPER than this (its next-epoch crate addressed under
3130/// an older root than the fan reaches) only heals via a fresh invite bundle — the
3131/// walk is strictly sequential, so a later rotation can't be reached either.
3132const MAX_ADDRESSING_ROOTS: usize = 8;
3133
3134/// Follow rekeys for a held community: advance the base (root) epoch and each
3135/// Private channel's epoch as far as authorized rotations allow, adopting the
3136/// fresh key we're still a recipient of at each step and dropping a scope we've
3137/// been removed from. Persists the result. Called when a rekey wrap arrives in
3138/// realtime so a long-running bot keeps decrypting after a rotation instead of
3139/// going silent.
3140///
3141/// **Authority (CORD-06 §Authority):** a BASE rotation is honored from the owner
3142/// only — the deliberate mirror of the owner-only Refounding send (a non-owner's
3143/// ban silences + strips; the read-cut is the owner's). A CHANNEL rotation is
3144/// honored from the owner or a `MANAGE_CHANNELS` holder under the PERSISTED
3145/// roster (folded + persisted by `follow_control`), minus the banlist — so an
3146/// admin-created private channel keys up on every member.
3147///
3148/// **Addressing fans across held base roots:** each channel step queries its
3149/// next-epoch rekey address under the current root AND the archived prior roots,
3150/// so a base adopt landing before a Refounding's prior-root-addressed channel
3151/// rekeys (or before a creation delivery minted under an older root) can't
3152/// strand the channel.
3153///
3154/// **Continuity + fork resolution are spec-strict:** a rotation must extend the
3155/// exact `(epoch, key)` I hold, one epoch at a time; a same-epoch fork resolves
3156/// by the lexicographically lowest new key ([`rekey::lowest_key_winner`]), so
3157/// every follower converges. An incomplete rotation (a missing chunk) never
3158/// concludes removal — it just waits. A KEYLESS channel (announced by vsk-2, key
3159/// not yet delivered) holds no chain, so continuity is vacuous for it (CORD-06
3160/// §2: "a convergence check, not a secrecy mechanism") — authority is its
3161/// boundary; its epoch is the scan cursor, advancing past complete rotations
3162/// that exclude us so the walk converges on the channel's current epoch.
3163pub async fn follow_rekeys<T: Transport + ?Sized>(
3164    transport: &T,
3165    community: &CommunityV2,
3166    session: &SessionGuard,
3167) -> Result<RekeyFollow, String> {
3168    // Death wins every race (CORD-02 §9): a dissolved community honors no epoch advance
3169    // past its tombstone — don't adopt a rotation into a grave.
3170    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3171    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
3172        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
3173    }
3174    // An offline member must also LEARN of a death: the tombstone rides its own
3175    // public plane, which the live sub watches but no catch-up fetch touched —
3176    // without this, a member who slept through a dissolution follows (and posts
3177    // into) a grave forever. Fail-open on transport failure: availability is
3178    // never death.
3179    if is_dissolved(transport, community).await {
3180        if session.is_valid() {
3181            let _ = crate::db::community::set_community_dissolved(&cid_hex);
3182        }
3183        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
3184    }
3185    let me = local_keys()?;
3186    let my_xonly = me.public_key().to_bytes();
3187    let owner = community.owner()?;
3188    let owner_hex = owner.to_hex();
3189    let mut cur = community.clone();
3190    let mut changed = false;
3191
3192    // The channel-rotator gates. Loaded once per follow: a roster change lands via
3193    // follow_control (which the worker runs right after this), so at worst an
3194    // admin's rotation adopts one pass late — never early.
3195    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3196    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3197    let me_hex = me.public_key().to_hex();
3198    let channel_rotator_ok = |rotator: &PublicKey| -> bool {
3199        if *rotator == owner {
3200            return true;
3201        }
3202        let rh = rotator.to_hex();
3203        !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
3204    };
3205    // Concluding MY removal takes more than the bit: the rotator must strictly
3206    // outrank ME (CORD-06 §Authority — "the Rotator must strictly outrank every
3207    // removed target"), so an equal-rank admin can never silently evict a peer
3208    // (or the owner) by minting a complete rotation that skips their blob.
3209    let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
3210        if *rotator == owner {
3211            return true;
3212        }
3213        let rh = rotator.to_hex();
3214        !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
3215    };
3216    let base_rotator_ok = |rotator: &PublicKey| -> bool { *rotator == owner };
3217
3218    // Bound the catch-up: each real step consumes a valid authorized rotation, so a
3219    // finite chain terminates naturally; the cap defends against a relay feeding a
3220    // pathological set.
3221    const MAX_STEPS: usize = 128;
3222    for _ in 0..MAX_STEPS {
3223        let mut advanced = false;
3224
3225        // The roots a channel rekey may be addressed under, freshest first: the
3226        // current root plus the archived priors (re-read each pass — a base adopt
3227        // below changes the head, and its predecessor is already archived).
3228        let mut addressing_roots: Vec<[u8; 32]> = vec![cur.community_root];
3229        let mut archived = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
3230        archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
3231        for (_, r) in archived {
3232            if !addressing_roots.contains(&r) {
3233                addressing_roots.push(r);
3234            }
3235        }
3236        addressing_roots.truncate(MAX_ADDRESSING_ROOTS);
3237
3238        // Private channels first: a removal-forced channel rekey rides the PRIOR
3239        // root (CORD-06 D2), so read channels before a base adopt moves it.
3240        let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
3241        for cid in channel_ids {
3242            let (held_key, held_epoch) = match cur.channel(&cid) {
3243                Some(ch) => (ch.key, ch.epoch),
3244                None => continue,
3245            };
3246            let next = Epoch(held_epoch.0.saturating_add(1));
3247            let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
3248            for root in &addressing_roots {
3249                let group = channel_rekey_group_key(root, &cid, next);
3250                let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
3251                if chunks.is_empty() {
3252                    continue;
3253                }
3254                batches.push((chunks, held_key.map(|k| (held_epoch, k))));
3255            }
3256            // Keyless-adopt residual (documented, deferred hardening): a malicious
3257            // AUTHORIZED admin can fork a keyless member onto an orphan low-key
3258            // rotation nothing extends (keyed members' continuity filters it out).
3259            // Recoverable via a fresh bundle; an insider with MANAGE_CHANNELS can
3260            // exclude the member outright anyway, so the marginal harm is the wedge
3261            // outliving their demotion.
3262            match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, me.secret_key(), &my_xonly, next) {
3263                Advance::Adopt { new_key } => {
3264                    if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
3265                        ch.key = Some(new_key);
3266                        ch.epoch = next;
3267                    }
3268                    // The adopter's own multi-epoch archive (the minter archived at
3269                    // mint) — this channel's history stays readable across rotations.
3270                    // fetch_channel compensates for the CURRENT epoch, so a failed
3271                    // archive only bites after the NEXT rotation — surface it.
3272                    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) {
3273                        crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
3274                    }
3275                    advanced = true;
3276                    changed = true;
3277                }
3278                Advance::Removed => {
3279                    match held_key {
3280                        // A complete rotation dropped my blob — cut from the channel.
3281                        Some(_) => {
3282                            cur.channels.retain(|c| c.id.0 != cid.0);
3283                        }
3284                        // Keyless scan: this epoch's rotation completed without me.
3285                        // Advance the cursor so the walk converges on the channel's
3286                        // CURRENT epoch — my entry point is its next rotation (whose
3287                        // recipients are the members at that time) or a fresh bundle.
3288                        None => {
3289                            if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
3290                                ch.epoch = next;
3291                            }
3292                        }
3293                    }
3294                    advanced = true;
3295                    changed = true;
3296                }
3297                Advance::Stay => {}
3298            }
3299        }
3300
3301        // Base rotation (Refounding): advances the root + root_epoch, re-addressing
3302        // every public channel, the guestbook, and the control plane by derivation
3303        // (refresh_subscription recomputes the author-set from the new root).
3304        {
3305            let held_epoch = cur.root_epoch;
3306            let held_key = cur.community_root;
3307            let next = Epoch(held_epoch.0.saturating_add(1));
3308            let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
3309            let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
3310            let batches = vec![(chunks, Some((held_epoch, held_key)))];
3311            match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_ok, me.secret_key(), &my_xonly, next) {
3312                Advance::Adopt { new_key } => {
3313                    cur.community_root = new_key;
3314                    cur.root_epoch = next;
3315                    // Archive on adopt: without this, a member who lived through TWO
3316                    // Refoundings loses the middle epoch's public history (only the
3317                    // minter archived it).
3318                    if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
3319                        crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
3320                    }
3321                    advanced = true;
3322                    changed = true;
3323                }
3324                Advance::Removed => {
3325                    if !session.is_valid() {
3326                        return Err("account changed during rekey follow".to_string());
3327                    }
3328                    return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
3329                }
3330                Advance::Stay => {}
3331            }
3332        }
3333
3334        if !advanced {
3335            break;
3336        }
3337    }
3338
3339    if !changed {
3340        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
3341    }
3342    if !session.is_valid() {
3343        return Err("account changed during rekey follow".to_string());
3344    }
3345    // A leave/delete raced this follow: saving would resurrect the community row
3346    // (the save is an upsert) with no floor rows behind it.
3347    if crate::db::community::community_protocol(community.id())?.is_none() {
3348        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
3349    }
3350    crate::db::community::save_community_v2(&cur)?;
3351    Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
3352}
3353
3354/// One scope's catch-up decision from the rekey chunks fetched at its next-epoch
3355/// address.
3356enum Advance {
3357    /// Adopt this fresh key for `next_epoch`.
3358    Adopt { new_key: [u8; 32] },
3359    /// A complete owner rotation at `next_epoch` dropped my blob — I'm removed.
3360    Removed,
3361    /// No owner rotation extends my held epoch (yet) — keep the current key.
3362    Stay,
3363}
3364
3365/// Fetch + parse every seal-verified 3303 chunk at a rekey plane address.
3366async fn fetch_rekey_chunks<T: Transport + ?Sized>(
3367    transport: &T,
3368    relays: &[String],
3369    group: &GroupKey,
3370) -> Result<Vec<rekey::RekeyChunk>, String> {
3371    // A rekey plane address is community_root-derived, so ANY member can seal junk
3372    // 3303s there — a flood (or, organically, a large community's own multi-chunk
3373    // rotation past the newest window) could bury the genuine owner/admin rotation
3374    // in a single fixed page. PAGE backwards (inclusive until + wrap-id dedup, the
3375    // control pager's discipline) so a buried authorized chunk is still recovered;
3376    // the seal + authority filter downstream drops the junk. Bounded — a sustained
3377    // flood past this depth degrades to "adopt one pass late", never a false state.
3378    const REKEY_PAGE: usize = 200;
3379    const REKEY_MAX_PAGES: usize = 6;
3380    let mut out = Vec::new();
3381    let mut seen: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
3382    let mut until: Option<u64> = None;
3383    let mut oldest: Option<u64> = None;
3384    for _ in 0..REKEY_MAX_PAGES {
3385        let query = Query {
3386            kinds: vec![stream::KIND_WRAP],
3387            authors: vec![group.pk_hex()],
3388            until,
3389            limit: Some(REKEY_PAGE),
3390            ..Default::default()
3391        };
3392        let wraps = transport.fetch(&query, relays).await?;
3393        let mut fresh = 0usize;
3394        for w in &wraps {
3395            if !seen.insert(w.id) {
3396                continue;
3397            }
3398            fresh += 1;
3399            let at = w.created_at.as_secs();
3400            if oldest.is_none_or(|o| at < o) {
3401                oldest = Some(at);
3402            }
3403            if let Ok(opened) = stream::open_wrap(w, group) {
3404                if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
3405                    out.push(chunk);
3406                }
3407            }
3408        }
3409        // Drained, or a same-second wall the pager can't step past (second-granular
3410        // until) — either way stop; the accumulated set is what advance_scope folds.
3411        if fresh == 0 || wraps.len() < REKEY_PAGE {
3412            break;
3413        }
3414        match oldest {
3415            Some(o) if o > 0 => until = Some(o),
3416            _ => break,
3417        }
3418    }
3419    Ok(out)
3420}
3421
3422/// Decide how a scope advances from per-addressing-root chunk batches (pure). Each
3423/// batch pairs the chunks fetched under one root with the continuity to demand of
3424/// them: a rotation qualifies when it's rotator-authorized (`rotator_ok`),
3425/// complete, targets the immediate `next_epoch`, and — when I hold a chain —
3426/// extends my exact `(epoch, key)`. A KEYLESS batch (`held` = None) has no chain
3427/// to extend, so it qualifies on authority + completeness alone (CORD-06 §2:
3428/// continuity is "a convergence check, not a secrecy mechanism"; the rotator's
3429/// seal authority is the boundary). Among qualifying rotations carrying my blob
3430/// the lexicographically lowest new key wins (convergent). All complete
3431/// candidates without my blob conclude Removed for a KEYED holder only when one
3432/// came from a rotator who may remove ME (`rotator_may_remove_me`, the CORD-06
3433/// strict-outrank rule) — else Stay; for a keyless holder they merely advance the
3434/// scan cursor (any bit-holder's real rotation is scan progress, never a loss).
3435fn advance_scope(
3436    batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
3437    scope: RekeyScope,
3438    rotator_ok: &dyn Fn(&PublicKey) -> bool,
3439    rotator_may_remove_me: &dyn Fn(&PublicKey) -> bool,
3440    my_sk: &SecretKey,
3441    my_xonly: &[u8; 32],
3442    next_epoch: Epoch,
3443) -> Advance {
3444    let mut winners: Vec<[u8; 32]> = Vec::new();
3445    let mut saw_complete_candidate = false;
3446    let mut saw_outranking_candidate = false;
3447    let keyed = batches.iter().any(|(_, held)| held.is_some());
3448    for (chunks, held) in batches {
3449        let rotations = rekey::collect_rotations(chunks);
3450        for r in &rotations {
3451            if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
3452                continue;
3453            }
3454            if let Some((held_epoch, held_key)) = held {
3455                if r.continuity(*held_epoch, held_key) != Continuity::Extends {
3456                    continue;
3457                }
3458            }
3459            saw_complete_candidate = true;
3460            saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
3461            if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
3462                if let Ok(k) = rekey::open_blob_local(my_sk, &r.rotator, r.scope, r.new_epoch, blob) {
3463                    winners.push(k);
3464                }
3465            }
3466        }
3467    }
3468    if !winners.is_empty() {
3469        // `collect_rotations` correlates on `(rotator, scope, new_epoch, prev_commit)`,
3470        // so a single rotator's blobs merge into ONE rotation (and a retried Refounding
3471        // MINT-OR-REUSES its root, so it never emits two distinct roots to fork on).
3472        // The lowest-key tiebreak engages only for CONCURRENT DISTINCT rotators racing
3473        // the same epoch (separate rotations): every follower converges on the same
3474        // lowest new key. A wrap served under two addressing roots can't double-count:
3475        // each rekey wrap opens under exactly one root's group key.
3476        let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
3477        return Advance::Adopt { new_key: winners[idx] };
3478    }
3479    if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
3480        Advance::Removed
3481    } else {
3482        Advance::Stay
3483    }
3484}
3485
3486#[cfg(test)]
3487mod tests {
3488    use super::super::super::transport::memory::MemoryRelay;
3489    use super::*;
3490    use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
3491
3492    /// A distinct npub-shaped account-dir name (bech32 charset) per counter.
3493    fn account_name(n: u32) -> String {
3494        const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3495        let mut acct = String::from("npub1");
3496        let mut v = n as usize;
3497        for _ in 0..58 {
3498            acct.push(B[v % 32] as char);
3499            v = v / 32 + 7;
3500        }
3501        acct
3502    }
3503
3504    /// One test participant: its identity keys and its isolated account DB dir.
3505    struct Actor {
3506        keys: Keys,
3507        account: String,
3508    }
3509
3510    /// Two participants sharing one relay but isolated per-account DBs — the
3511    /// cross-account harness a real invite/join loop needs. `swap_to` mirrors a
3512    /// live `swap_session`: re-point the DB pool + rebind the identity + clear
3513    /// the per-account id caches, so account A's community is invisible to B
3514    /// until B legitimately joins.
3515    struct TestBed {
3516        _tmp: tempfile::TempDir,
3517        _guard: std::sync::MutexGuard<'static, ()>,
3518        relay: MemoryRelay,
3519        relays: Vec<String>,
3520    }
3521
3522    impl TestBed {
3523        fn new() -> (TestBed, Actor, Actor) {
3524            static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
3525            let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3526            crate::db::close_database();
3527            crate::db::clear_id_caches();
3528            let tmp = tempfile::tempdir().unwrap();
3529            crate::db::set_app_data_dir(tmp.path().to_path_buf());
3530
3531            let mut mk = || {
3532                let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3533                let account = account_name(n);
3534                std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3535                crate::db::set_current_account(account.clone()).unwrap();
3536                crate::db::init_database(&account).unwrap();
3537                Actor { keys: Keys::generate(), account }
3538            };
3539            let owner = mk();
3540            let member = mk();
3541            let _ = crate::state::take_nostr_client();
3542            let bed = TestBed {
3543                _tmp: tmp,
3544                _guard: guard,
3545                relay: MemoryRelay::new(),
3546                relays: vec!["wss://r".to_string()],
3547            };
3548            (bed, owner, member)
3549        }
3550
3551        /// Become `actor`: swap the account DB + identity, as a real session swap.
3552        fn swap_to(&self, actor: &Actor) {
3553            crate::db::set_current_account(actor.account.clone()).unwrap();
3554            crate::db::init_database(&actor.account).unwrap();
3555            crate::db::clear_id_caches();
3556            crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
3557            crate::state::set_my_public_key(actor.keys.public_key());
3558        }
3559    }
3560
3561    /// Legacy single-actor helper (the create/send tests below).
3562    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
3563        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3564        crate::db::close_database();
3565        crate::db::clear_id_caches();
3566        let tmp = tempfile::tempdir().unwrap();
3567        static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
3568        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3569        let acct = account_name(n);
3570        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
3571        crate::db::set_app_data_dir(tmp.path().to_path_buf());
3572        crate::db::set_current_account(acct.clone()).unwrap();
3573        crate::db::init_database(&acct).unwrap();
3574        let _ = crate::state::take_nostr_client();
3575        let owner = Keys::generate();
3576        crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
3577        crate::state::set_my_public_key(owner.public_key());
3578        (tmp, guard, owner)
3579    }
3580
3581    /// A transport that simulates a session swap landing DURING a fetch await —
3582    /// so a join straddling the fetch sees an invalid session and aborts.
3583    struct SwapMidFetch {
3584        inner: MemoryRelay,
3585    }
3586    #[async_trait::async_trait]
3587    impl Transport for SwapMidFetch {
3588        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
3589            self.inner.publish(e, r).await
3590        }
3591        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
3592            self.inner.publish_durable(e, r).await
3593        }
3594        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
3595            let out = self.inner.fetch(q, r).await;
3596            crate::state::bump_session_generation();
3597            out
3598        }
3599    }
3600
3601    /// A transport whose `fetch` returns a FIXED, UNSORTED event list — modelling
3602    /// the production `LiveTransport` union (first-responding relay's batch, no
3603    /// global newest-first sort), which `MemoryRelay` hides by sorting. This is
3604    /// the only harness that can exercise the revocation-race ordering.
3605    struct FixedFetch {
3606        events: Vec<Event>,
3607    }
3608    #[async_trait::async_trait]
3609    impl Transport for FixedFetch {
3610        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
3611            Ok(())
3612        }
3613        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
3614            Ok(())
3615        }
3616        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
3617            Ok(self.events.clone())
3618        }
3619    }
3620
3621    /// Fetch a pending Direct Invite (kind 3313 giftwrap) addressed to `me` — the
3622    /// indexed inbox query CORD-05 §6 defines: `{1059, #p:[me], #k:["3313"]}`.
3623    async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
3624        let q = Query {
3625            kinds: vec![stream::KIND_WRAP],
3626            p_tags: vec![me.to_hex()],
3627            k_tags: vec!["3313".to_string()],
3628            ..Default::default()
3629        };
3630        relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
3631    }
3632
3633    #[tokio::test]
3634    async fn create_persists_and_reloads_a_v2_community() {
3635        let (_tmp, _guard, owner) = init_test_db();
3636        let relay = MemoryRelay::new();
3637        let relays = vec!["wss://r".to_string()];
3638
3639        let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
3640        assert!(created.identity.verify());
3641        assert_eq!(created.owner().unwrap(), owner.public_key());
3642        assert_eq!(created.channels.len(), 1);
3643
3644        // Protocol dispatch sees it as v2, and it reloads byte-faithfully.
3645        assert_eq!(
3646            crate::db::community::community_protocol(created.id()).unwrap(),
3647            Some(crate::community::ConcordProtocol::V2)
3648        );
3649        let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
3650        assert_eq!(loaded.name, "Vectorville");
3651        assert_eq!(loaded.community_root, created.community_root);
3652        assert_eq!(loaded.identity, created.identity);
3653        assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
3654        assert!(!loaded.channels[0].private);
3655
3656        // The genesis control editions + the owner Join landed on the relay.
3657        assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
3658    }
3659
3660    #[tokio::test]
3661    async fn owner_sends_and_reads_back_a_message() {
3662        let (_tmp, _guard, _owner) = init_test_db();
3663        let relay = MemoryRelay::new();
3664        let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
3665        let general = community.channels[0].id;
3666
3667        let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
3668        let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
3669        assert_ne!(id1, id2);
3670
3671        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
3672        let texts: Vec<String> = page
3673            .iter()
3674            .filter_map(|f| match &f.event {
3675                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
3676                _ => None,
3677            })
3678            .collect();
3679        assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
3680    }
3681
3682    #[tokio::test]
3683    async fn a_second_member_reads_the_public_channel_from_the_root() {
3684        // A member who holds the community_root (via an invite bundle, modeled
3685        // here by cloning the community) reads the owner's public-channel message
3686        // — public channels need no key delivery, they derive from the root.
3687        let (_tmp, _guard, _owner) = init_test_db();
3688        let relay = MemoryRelay::new();
3689        let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
3690        let general = community.channels[0].id;
3691        send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
3692
3693        // The "member" reconstructs the same read coordinates from the root.
3694        let member_view = community.clone();
3695        let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
3696        assert_eq!(page.len(), 1);
3697        assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
3698        assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
3699    }
3700
3701    // ── Two-actor end-to-end (the create → invite → join → message loop) ──────
3702
3703    async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
3704        fetch_channel(relay, community, channel, 100)
3705            .await
3706            .unwrap()
3707            .iter()
3708            .filter_map(|f| match &f.event {
3709                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
3710                _ => None,
3711            })
3712            .collect()
3713    }
3714
3715    #[tokio::test]
3716    async fn direct_invite_full_loop_owner_and_member_converse() {
3717        let (bed, owner, member) = TestBed::new();
3718
3719        // Owner creates a community, posts, and Direct-Invites the member's npub.
3720        bed.swap_to(&owner);
3721        let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
3722        let general = community.channels[0].id;
3723        send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
3724        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
3725
3726        // Member (a DIFFERENT account, no prior knowledge) finds + accepts the invite.
3727        bed.swap_to(&member);
3728        assert!(
3729            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
3730            "the member does not hold the community before joining"
3731        );
3732        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
3733        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
3734        assert_eq!(joined.id().0, community.id().0, "joined the same community");
3735        assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
3736        assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
3737
3738        // The member reads the owner's public-channel history and replies.
3739        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
3740        send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
3741
3742        // The owner reads the member's reply.
3743        bed.swap_to(&owner);
3744        assert_eq!(
3745            texts_in(&bed.relay, &community, &general).await,
3746            vec!["owner: welcome!", "member: thanks for the invite"],
3747            "both actors' messages interleave in ms order on the shared channel"
3748        );
3749
3750        // The Guestbook memberlist now folds both participants.
3751        let members = memberlist(&bed.relay, &community).await.unwrap();
3752        assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
3753        assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
3754        assert_eq!(members.len(), 2);
3755    }
3756
3757    #[tokio::test]
3758    async fn public_link_full_loop() {
3759        let (bed, owner, member) = TestBed::new();
3760
3761        bed.swap_to(&owner);
3762        let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
3763        let general = community.channels[0].id;
3764        send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
3765        // Mint a shareable link (a non-stock relay so the fragment carries it).
3766        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3767        assert!(link.url.starts_with("https://vectorapp.io/invite/"));
3768        assert!(link.url.contains('#'), "the fragment carries the token");
3769
3770        // Member joins purely from the URL string.
3771        bed.swap_to(&member);
3772        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
3773        assert_eq!(joined.id().0, community.id().0);
3774        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
3775    }
3776
3777    #[test]
3778    fn bundle_of_snapshots_the_held_icon() {
3779        let owner = Keys::generate();
3780        let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
3781        let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
3782        let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
3783        c.icon = Some(icon.clone());
3784        let bundle = bundle_of(&c, None, None, None);
3785        assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
3786    }
3787
3788    #[tokio::test]
3789    async fn public_link_preview_shows_live_name_and_icon_without_joining() {
3790        let (bed, owner, member) = TestBed::new();
3791
3792        bed.swap_to(&owner);
3793        let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
3794        // The icon lives on the Control Plane, never in the bundle — publish it
3795        // as a metadata edition so the preview must FOLD to see it.
3796        let icon = control::ImageRef {
3797            url: "https://blossom.example/soap".into(),
3798            key: "k".into(),
3799            nonce: "n".into(),
3800            hash: "h".into(),
3801            extra: Default::default(),
3802        };
3803        let mut meta = community.metadata();
3804        meta.icon = Some(icon.clone());
3805        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
3806        // An any-host base — the naddr#fragment payload is domain-agnostic.
3807        let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
3808
3809        // A NON-member previews: the real name + the live icon, nothing persisted.
3810        bed.swap_to(&member);
3811        let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
3812        assert_eq!(preview.name, "Soapbox");
3813        assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
3814        assert!(
3815            crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
3816            "previewing must not persist a membership"
3817        );
3818    }
3819
3820    #[tokio::test]
3821    async fn a_previewed_join_reuses_the_verified_fold() {
3822        let (bed, owner, member) = TestBed::new();
3823        bed.swap_to(&owner);
3824        let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
3825        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3826
3827        bed.swap_to(&member);
3828        let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
3829        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
3830        assert_eq!(joined.id().0, community.id().0);
3831        assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
3832        // The slot was CONSUMED by the join — proving the handoff path ran (a
3833        // verify re-walk would have left the preview's entry in place).
3834        assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
3835    }
3836
3837    #[tokio::test]
3838    async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
3839        let (bed, owner, member) = TestBed::new();
3840        bed.swap_to(&owner);
3841        let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
3842        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3843
3844        bed.swap_to(&member);
3845        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
3846
3847        // Seed from zero: the stored fold equals the authoritative live fold.
3848        let session = SessionGuard::capture();
3849        assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the seed folds fresh events");
3850        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
3851        let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
3852        assert!(cursor > 0, "the cursor advanced past zero");
3853        let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
3854        let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
3855        assert_eq!(stored, live, "stored fold == live fold after the seed");
3856        assert!(stored.contains(&member.keys.public_key()));
3857
3858        // Nothing new on the plane → an idle re-sync folds nothing.
3859        assert!(sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty());
3860
3861        // The owner kicks the member; a CURSOR catch-up folds the kick in — no full walk.
3862        bed.swap_to(&owner);
3863        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
3864        bed.swap_to(&member);
3865        let session = SessionGuard::capture();
3866        assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the kick lands incrementally");
3867        assert!(
3868            !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
3869            "an owner kick removes the member from the stored fold"
3870        );
3871    }
3872
3873    #[tokio::test]
3874    async fn a_preview_then_revoke_still_refuses_the_join() {
3875        let (bed, owner, member) = TestBed::new();
3876        bed.swap_to(&owner);
3877        let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
3878        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3879
3880        // Member previews (warming the verified handoff), THEN the owner revokes.
3881        bed.swap_to(&member);
3882        let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
3883        assert_eq!(p.name, "RevokeRace");
3884        bed.swap_to(&owner);
3885        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
3886        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
3887
3888        // The join MUST refuse: the handoff skips only the root re-verify, never
3889        // the bundle re-fetch that carries the revocation gate.
3890        bed.swap_to(&member);
3891        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
3892        assert!(err.contains("revoked"), "got: {err}");
3893    }
3894
3895    #[tokio::test]
3896    async fn a_revoked_link_refuses_to_join() {
3897        let (bed, owner, member) = TestBed::new();
3898        bed.swap_to(&owner);
3899        let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
3900        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3901        // Owner retires the link (re-posts the coordinate as a tombstone).
3902        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
3903        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
3904
3905        bed.swap_to(&member);
3906        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
3907        assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
3908    }
3909
3910    #[tokio::test]
3911    async fn an_expired_direct_invite_refuses_to_join() {
3912        let (bed, owner, member) = TestBed::new();
3913        bed.swap_to(&owner);
3914        let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
3915        // Hand-mint an invite that expired in the past.
3916        let inviter = owner.keys.clone();
3917        let mut bundle = bundle_of(&community, Some(inviter.public_key()), Some(1_000), None);
3918        bundle.expires_at = Some(1_000); // unix ms, long past
3919        let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
3920        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
3921
3922        bed.swap_to(&member);
3923        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
3924        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
3925        assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
3926    }
3927
3928    #[tokio::test]
3929    async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
3930        // The revocation-durability fix: if ANY signer-valid tombstone is among the
3931        // fetched events, refuse — even when a Live bundle is returned FIRST (the
3932        // production union has no newest-first sort, so a stale relay's Live can lead).
3933        let (bed, owner, member) = TestBed::new();
3934        bed.swap_to(&owner);
3935        let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
3936        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3937        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
3938
3939        // A relay union that hands back [Live, tombstone] — Live FIRST. Old
3940        // `events.first()` would join the Live; the scan-all fix must refuse.
3941        let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
3942
3943        bed.swap_to(&member);
3944        let err = accept_public_link(&union, &link.url).await.unwrap_err();
3945        assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
3946    }
3947
3948    #[test]
3949    fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
3950        // The accept-side DoS bound: from_bundle (which accept_bundle calls)
3951        // rejects a >256-channel bundle via validate() BEFORE the Vec allocation.
3952        // (The Direct-Invite wire path is additionally bounded by NIP-44's 64KB
3953        // cap, which trips even earlier — but the count guard is the real defense
3954        // for the single-layer public-link bundle.)
3955        let owner = Keys::generate();
3956        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
3957        let hex = crate::simd::hex::bytes_to_hex_32;
3958        let root = [0x11u8; 32];
3959        let mut bundle = CommunityInvite {
3960            community_id: hex(&identity.community_id.0),
3961            owner: hex(&identity.owner_xonly),
3962            owner_salt: hex(&identity.owner_salt),
3963            community_root: hex(&root),
3964            root_epoch: 0,
3965            channels: vec![],
3966            relays: vec!["wss://r".into()],
3967            name: "X".into(),
3968            icon: None,
3969            expires_at: None,
3970            creator_npub: None,
3971            label: None,
3972            extra: Default::default(),
3973        };
3974        bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
3975            .map(|i| {
3976                let mut id = [0u8; 32];
3977                id[..8].copy_from_slice(&(i as u64).to_be_bytes());
3978                invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
3979            })
3980            .collect();
3981        assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
3982    }
3983
3984    #[tokio::test]
3985    async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
3986        // The SessionGuard straddle: a public-link accept fetches then saves. If the
3987        // account swaps in that window, the join must abort — never write A's
3988        // community into B's DB. SwapMidFetch bumps the session generation during
3989        // the fetch await, exactly as a real swap_session would.
3990        let (bed, owner, member) = TestBed::new();
3991        bed.swap_to(&owner);
3992        let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
3993        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3994        // A fresh swap-injecting transport holding the same bundle event.
3995        let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
3996        swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
3997
3998        bed.swap_to(&member);
3999        let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
4000        assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
4001        assert!(
4002            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
4003            "the aborted join wrote nothing to the (member) account DB"
4004        );
4005    }
4006
4007    #[tokio::test]
4008    async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
4009        // The owner is derived from the self-certifying community_id, so the
4010        // memberlist includes them independent of any Guestbook fetch.
4011        let (_tmp, _guard, owner) = init_test_db();
4012        let relay = MemoryRelay::new();
4013        let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
4014        // A memberlist over an EMPTY guestbook (fetch a community-relay-less view)
4015        // still contains the owner.
4016        let empty = MemoryRelay::new();
4017        let members = memberlist(&empty, &community).await.unwrap();
4018        assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
4019    }
4020
4021    #[tokio::test]
4022    async fn an_expiring_minted_invite_refuses_after_the_deadline() {
4023        // The mint path can now produce an expiring invite, and the accept gate
4024        // trips on it (end-to-end through the real service, not a hand-built bundle).
4025        let (bed, owner, member) = TestBed::new();
4026        bed.swap_to(&owner);
4027        let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
4028        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
4029            .await
4030            .unwrap();
4031
4032        bed.swap_to(&member);
4033        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4034        assert!(
4035            accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
4036            "a minted expiring invite refuses past its deadline"
4037        );
4038    }
4039
4040    #[tokio::test]
4041    async fn a_member_who_leaves_drops_from_the_memberlist() {
4042        let (bed, owner, member) = TestBed::new();
4043        bed.swap_to(&owner);
4044        let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
4045        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
4046
4047        bed.swap_to(&member);
4048        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4049        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
4050        // Let the leave land strictly after the join.
4051        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
4052        leave_community(&bed.relay, &joined).await.unwrap();
4053
4054        bed.swap_to(&owner);
4055        let members = memberlist(&bed.relay, &community).await.unwrap();
4056        assert!(members.contains(&owner.keys.public_key()));
4057        assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
4058    }
4059
4060    #[tokio::test]
4061    async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
4062        // Multi-account isolation: after the swap, the member's DB holds nothing
4063        // of the owner's community — the dual-stack storage is per-account.
4064        let (bed, owner, member) = TestBed::new();
4065        bed.swap_to(&owner);
4066        let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
4067        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
4068
4069        bed.swap_to(&member);
4070        assert!(
4071            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
4072            "the owner's community must be invisible in the member's account DB"
4073        );
4074        assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
4075    }
4076
4077    // ── Live control-follow ──────────────────────────────────────────────────
4078
4079    /// Publish an owner-grammar channel edition straight to the control plane,
4080    /// signed by `signer` (the owner for a legit edit, a stranger for the
4081    /// authority test). `version`/`deleted` drive add-vs-rename-vs-delete.
4082    /// The entity's current head `self_hash` on the relay (highest version wins),
4083    /// so a helper can chain a new edition the way a real owner client does.
4084    async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
4085        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4086        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
4087        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
4088        let mut head: Option<(u64, [u8; 32])> = None;
4089        for w in &wraps {
4090            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
4091                if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
4092                    head = Some((ed.version, ed.self_hash));
4093                }
4094            }
4095        }
4096        head.map(|(_, h)| h)
4097    }
4098
4099    async fn publish_channel_edition(
4100        relay: &MemoryRelay,
4101        community: &CommunityV2,
4102        signer: &Keys,
4103        channel_id: &ChannelId,
4104        name: &str,
4105        private: bool,
4106        version: u64,
4107        deleted: bool,
4108    ) {
4109        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4110        let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
4111        let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
4112        let content = serde_json::to_string(&meta).unwrap();
4113        let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
4114        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4115        relay.publish(&wrap, &community.relays).await.unwrap();
4116    }
4117
4118    /// Publish an owner-grammar community-metadata edition (rename etc.), chained
4119    /// to the current relay head like a real owner client.
4120    async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
4121        publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
4122    }
4123
4124    /// As [`publish_community_meta`] with an explicit timestamp, for tests that need
4125    /// relay-side newest-first ordering (paging/eviction scenarios).
4126    async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
4127        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4128        let prev = head_hash_on_relay(relay, community, &community.id().0).await;
4129        let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
4130        let content = serde_json::to_string(&meta).unwrap();
4131        let rumor = control::build_edition_rumor(signer.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, version, prev.as_ref(), &content, at_secs, None);
4132        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
4133        relay.publish(&wrap, &community.relays).await.unwrap();
4134    }
4135
4136    #[test]
4137    fn metadata_apply_captures_undriven_fields_for_republish() {
4138        let owner = Keys::generate();
4139        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
4140        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
4141        let general = held.channels[0].id;
4142
4143        // A foreign vsk-0 head carrying custom + unknown fields folds them in…
4144        let mut custom = serde_json::Map::new();
4145        custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
4146        let mut extra = serde_json::Map::new();
4147        extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
4148        let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
4149        assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
4150        assert_eq!(held.meta_custom, Some(custom.clone()));
4151        assert_eq!(held.meta_extra, extra);
4152        // …and the next local edit's base document republishes them verbatim.
4153        assert_eq!(held.metadata().custom, Some(custom));
4154        assert_eq!(held.metadata().extra, held.meta_extra);
4155
4156        // Same contract for a vsk-2 channel head (voice included).
4157        let mut ch_custom = serde_json::Map::new();
4158        ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
4159        let ch_meta = control::ChannelMetadata {
4160            name: "general".into(),
4161            private: false,
4162            voice: Some(true),
4163            deleted: None,
4164            custom: Some(ch_custom.clone()),
4165            extra: Default::default(),
4166        };
4167        assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
4168        let ch = held.channel(&general).unwrap();
4169        assert_eq!(ch.voice, Some(true));
4170        assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
4171        let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
4172        assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
4173        assert_eq!(rename.custom, Some(ch_custom));
4174    }
4175
4176    #[test]
4177    fn community_metadata_apply_sets_and_clears_images() {
4178        let owner = Keys::generate();
4179        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
4180        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
4181
4182        let icon = control::ImageRef {
4183            url: "https://blossom.example/i".into(),
4184            key: "k".into(),
4185            nonce: "n".into(),
4186            hash: "h".into(),
4187            extra: Default::default(),
4188        };
4189        let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
4190        assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
4191        assert_eq!(held.icon.as_ref(), Some(&icon));
4192
4193        // An edition is the FULL document: a head without the icon removes it.
4194        let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
4195        assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
4196        assert_eq!(held.icon, None);
4197    }
4198
4199    /// Publish a Role edition (vsk 1) signed by `signer`, chained to the current head.
4200    async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
4201        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4202        let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
4203        let prev = head_hash_on_relay(relay, community, &role_id).await;
4204        let content = crate::community::v2::roles::role_content_json(role).unwrap();
4205        let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, None);
4206        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4207        relay.publish(&wrap, &community.relays).await.unwrap();
4208    }
4209
4210    /// Publish a Grant edition (vsk 3) signed by `signer`, at grant_locator(cid, member).
4211    async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
4212        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4213        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
4214        let prev = head_hash_on_relay(relay, community, &eid).await;
4215        let grant = MemberGrant { member: member.to_hex(), role_ids };
4216        let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
4217        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, None);
4218        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4219        relay.publish(&wrap, &community.relays).await.unwrap();
4220    }
4221
4222    /// Publish a Banlist edition (vsk 4) signed by `signer`, at banlist_locator(cid).
4223    async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
4224        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4225        let eid = crate::community::v2::derive::banlist_locator(community.id());
4226        let prev = head_hash_on_relay(relay, community, &eid).await;
4227        let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
4228        let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, None);
4229        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4230        relay.publish(&wrap, &community.relays).await.unwrap();
4231    }
4232
4233    fn admin_role(role_id: &str, perms: u64) -> Role {
4234        Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
4235    }
4236
4237    // ── CORD-04 §1 author-aware fold: a seat-holder (holds community_root, so can seal
4238    // any control edition) must not be able to SUPPRESS a role or grant by forging a
4239    // higher version at its coordinate. Owner-only signers mask this entirely, so every
4240    // attacker below signs as a NON-owner member.
4241
4242    #[tokio::test]
4243    async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
4244        let (bed, owner, attacker) = TestBed::new();
4245        bed.swap_to(&owner);
4246        let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
4247        let victim = Keys::generate().public_key();
4248        grant_admin(&bed.relay, &community, &victim).await.unwrap();
4249
4250        // The admin role sits at a deterministic, publicly-computable coordinate.
4251        let admin_rid = fetch_authority(&bed.relay, &community)
4252            .await
4253            .roles
4254            .roles
4255            .iter()
4256            .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
4257            .unwrap()
4258            .role_id
4259            .clone();
4260        // Attacker forges v2 of that exact role, stripping its powers.
4261        publish_role(
4262            &bed.relay,
4263            &community,
4264            &attacker.keys,
4265            &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
4266            2,
4267        )
4268        .await;
4269
4270        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
4271        assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
4272        assert!(
4273            authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
4274            "the floor advances only to the AUTHORIZED head (owner v1)"
4275        );
4276        assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
4277    }
4278
4279    #[tokio::test]
4280    async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
4281        let (bed, owner, attacker) = TestBed::new();
4282        bed.swap_to(&owner);
4283        let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
4284        let victim = Keys::generate();
4285        grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
4286
4287        // Attacker forges a higher-version EMPTY grant at the victim's grant coordinate.
4288        publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
4289
4290        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
4291        assert!(
4292            authority.roles.is_admin(&victim.public_key().to_hex()),
4293            "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
4294        );
4295    }
4296
4297    #[tokio::test]
4298    async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
4299        let (bed, owner, attacker) = TestBed::new();
4300        bed.swap_to(&owner);
4301        let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
4302        let victim = Keys::generate().public_key();
4303        grant_admin(&bed.relay, &community, &victim).await.unwrap();
4304
4305        // Low-id roles that WOULD evict the admin from a pre-authorize cap — but they're
4306        // unauthorized, so the post-authorize cap never sees them.
4307        for i in 0u8..6 {
4308            let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
4309            publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4310        }
4311
4312        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
4313        assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
4314        assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
4315    }
4316
4317    /// A canonical (order-independent) fingerprint of an AuthoritySet's authorized
4318    /// roster + banlist — two clients converge iff these match.
4319    fn authority_fingerprint(a: &AuthoritySet) -> String {
4320        let mut roles = a.roles.roles.clone();
4321        roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
4322        let mut grants = a.roles.grants.clone();
4323        for g in &mut grants {
4324            g.role_ids.sort();
4325        }
4326        grants.sort_by(|x, y| x.member.cmp(&y.member));
4327        let banned: Vec<&String> = a.banned.iter().collect();
4328        serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
4329    }
4330
4331    #[tokio::test]
4332    async fn the_v2_authority_fold_is_order_independent() {
4333        // THE core consensus property: two honest clients that receive the SAME
4334        // control editions in DIFFERENT arrival orders must resolve the IDENTICAL
4335        // authorized roster + banlist (author-aware select_authorized + banlist
4336        // fold + cap, all deterministic). A divergence here would fork the
4337        // community's moderation state between honest members.
4338        let (bed, owner, _a) = TestBed::new();
4339        bed.swap_to(&owner);
4340        let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
4341
4342        // A rich control plane: two admins, an extra role, two grants (one of them a
4343        // grant to a member the owner then bans), a banlist, a rename, a channel.
4344        let admin1 = Keys::generate().public_key();
4345        let admin2 = Keys::generate().public_key();
4346        grant_admin(&bed.relay, &community, &admin1).await.unwrap();
4347        grant_admin(&bed.relay, &community, &admin2).await.unwrap();
4348        let mod_rid = "5c".repeat(32);
4349        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
4350        let member = Keys::generate().public_key();
4351        publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
4352        let banned_member = Keys::generate().public_key();
4353        publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
4354        set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
4355        let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
4356        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
4357        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
4358
4359        let editions = fetch_control(&bed.relay, &community).await;
4360        let floors = load_floors(&community);
4361        assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
4362
4363        let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
4364
4365        // Fold under many arrival permutations: reversed, and several deterministic
4366        // rotations/interleavings. Every one must match the baseline.
4367        let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
4368        let mut rev = editions.clone();
4369        rev.reverse();
4370        orders.push(rev);
4371        for shift in [1usize, 3, 5, 7] {
4372            let n = editions.len();
4373            orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
4374        }
4375        // A deterministic "shuffle": interleave from both ends.
4376        let mut zip = Vec::with_capacity(editions.len());
4377        let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
4378        while lo <= hi {
4379            zip.push(editions[lo as usize].clone());
4380            if lo != hi {
4381                zip.push(editions[hi as usize].clone());
4382            }
4383            lo += 1;
4384            hi -= 1;
4385        }
4386        orders.push(zip);
4387
4388        for (i, order) in orders.iter().enumerate() {
4389            let got = authority_fingerprint(&fold_authority(&community, order, &floors));
4390            assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
4391        }
4392        // Sanity: the fingerprint reflects real state (the banned member is out, the
4393        // honest admins are in).
4394        assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
4395        assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
4396    }
4397
4398    /// A transport that ACKs publishes but ERRORS every fetch — a relay outage / withhold.
4399    struct FetchErrors(MemoryRelay);
4400    #[async_trait::async_trait]
4401    impl crate::community::transport::Transport for FetchErrors {
4402        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
4403            self.0.publish(e, r).await
4404        }
4405        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
4406            Err("relay down".to_string())
4407        }
4408        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
4409            self.0.publish_durable(e, r).await
4410        }
4411    }
4412
4413    #[tokio::test]
4414    async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
4415        let (bed, owner, victim) = TestBed::new();
4416        bed.swap_to(&owner);
4417        let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
4418        let victim_hex = victim.keys.public_key().to_hex();
4419        // A ban is persisted locally (as a completed set_banlist + follow leaves it).
4420        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4421        crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
4422
4423        // A relay that ERRORS on fetch must degrade FAIL-SAFE: retain the ban, never
4424        // return an empty banlist (which would silently un-ban on withheld data).
4425        let down = FetchErrors(MemoryRelay::new());
4426        let view = fetch_authority(&down, &community).await;
4427        assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
4428    }
4429
4430    #[tokio::test]
4431    async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
4432        let (bed, owner, _m) = TestBed::new();
4433        bed.swap_to(&owner);
4434        let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
4435        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4436        let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
4437        let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
4438
4439        // Full state on relay1: an Admin role + two grants → both fold + persist as admins.
4440        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4441        publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
4442        publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
4443        let session = crate::state::SessionGuard::capture();
4444        follow_control(&bed.relay, &community, &session).await.unwrap();
4445        assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
4446
4447        // relay2 serves A's grant but NOT the role (aged out of the window): the fold
4448        // drops both admins yet raises no gap. The completeness gate must RETAIN the
4449        // stored roster rather than persist the lossy one.
4450        let relay2 = MemoryRelay::new();
4451        publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
4452        follow_control(&relay2, &community, &session).await.unwrap();
4453        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
4454        assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
4455    }
4456
4457    #[tokio::test]
4458    async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
4459        // CORD-04 §5: an admin holding MANAGE_METADATA renames the community; once the
4460        // owner revokes the grant, the (now unauthorized) admin's further edit drops
4461        // and the name holds at the last authorized state.
4462        let (_tmp, _guard, owner) = init_test_db();
4463        let relay = MemoryRelay::new();
4464        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
4465        let admin = Keys::generate();
4466        let rid = "a1".repeat(32);
4467        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
4468        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
4469        publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
4470
4471        let session = SessionGuard::capture();
4472        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
4473        assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
4474
4475        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke
4476        publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
4477        let _ = follow_control(&relay, &community, &session).await.unwrap();
4478        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4479        assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
4480    }
4481
4482    #[tokio::test]
4483    async fn a_roleless_member_cannot_edit_metadata() {
4484        let (_tmp, _guard, _owner) = init_test_db();
4485        let relay = MemoryRelay::new();
4486        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
4487        let stranger = Keys::generate();
4488        publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
4489        let session = SessionGuard::capture();
4490        assert!(
4491            follow_control(&relay, &community, &session).await.unwrap().is_none(),
4492            "a roleless member's metadata edit never folds"
4493        );
4494    }
4495
4496    #[tokio::test]
4497    async fn a_self_signed_grant_is_not_authority() {
4498        // The self-promotion defense: a member self-signs both a role and a grant of
4499        // it to themselves. authorize_delegation drops both (their signer never traces
4500        // to the owner), so their metadata edit stays unauthorized.
4501        let (_tmp, _guard, _owner) = init_test_db();
4502        let relay = MemoryRelay::new();
4503        let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
4504        let rogue = Keys::generate();
4505        let rid = "b2".repeat(32);
4506        publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4507        publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
4508        publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
4509        let session = SessionGuard::capture();
4510        assert!(
4511            follow_control(&relay, &community, &session).await.unwrap().is_none(),
4512            "a self-signed grant confers no authority"
4513        );
4514    }
4515
4516    #[tokio::test]
4517    async fn the_banlist_is_enforced_only_from_a_ban_holder() {
4518        let (_tmp, _guard, owner) = init_test_db();
4519        let relay = MemoryRelay::new();
4520        let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
4521        let target = "cc".repeat(32);
4522
4523        // A non-BAN-holder's banlist edition is folded but NOT enforced.
4524        let rogue = Keys::generate();
4525        publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
4526        let floors = load_floors(&community);
4527        let editions = fetch_control(&relay, &community).await;
4528        let authority = fold_authority(&community, &editions, &floors);
4529        assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
4530
4531        // The owner (supreme, holds BAN) bans the target: now enforced.
4532        publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
4533        let editions = fetch_control(&relay, &community).await;
4534        let authority = fold_authority(&community, &editions, &floors);
4535        assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
4536    }
4537
4538    #[tokio::test]
4539    async fn a_banned_admin_loses_all_authority() {
4540        // CORD-04 §4: a banned npub vanishes — even holding an un-stripped grant, a
4541        // banned admin's authority is dropped and their edits refused.
4542        let (_tmp, _guard, owner) = init_test_db();
4543        let relay = MemoryRelay::new();
4544        let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
4545        let admin = Keys::generate();
4546        let rid = "e5".repeat(32);
4547        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
4548        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
4549        publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; // ban, grant left intact
4550        publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
4551
4552        let session = SessionGuard::capture();
4553        assert!(
4554            follow_control(&relay, &community, &session).await.unwrap().is_none(),
4555            "a banned admin's edit is dropped even with an unstripped grant"
4556        );
4557        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4558        assert!(authority.banned.contains(&admin.public_key().to_hex()));
4559        assert!(
4560            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
4561            "a banned admin holds no bit"
4562        );
4563    }
4564
4565    #[tokio::test]
4566    async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
4567        // CORD-04 §3/§5: BAN needs the bit AND a strict outrank of the target. A mod
4568        // (pos 2, holds BAN) can ban a lower member but NOT a superior admin (pos 1)
4569        // and NOT the owner (supreme, unbannable).
4570        let (_tmp, _guard, owner) = init_test_db();
4571        let relay = MemoryRelay::new();
4572        let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
4573        let admin = Keys::generate();
4574        let moder = Keys::generate();
4575        let stranger = Keys::generate();
4576        let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
4577        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;
4578        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;
4579        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
4580        publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
4581        publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
4582
4583        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4584        assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
4585        assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
4586        assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
4587    }
4588
4589    #[tokio::test]
4590    async fn an_unauthorized_higher_banlist_cannot_unban() {
4591        // CORD-04 §4 anti-roster fail-CLOSED: a rogue's higher-version empty banlist
4592        // must not erase the owner's ban (author-aware head selection + persisted
4593        // banlist retention).
4594        let (_tmp, _guard, owner) = init_test_db();
4595        let relay = MemoryRelay::new();
4596        let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
4597        let target = "cc".repeat(32);
4598        publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
4599        let session = SessionGuard::capture();
4600        follow_control(&relay, &community, &session).await.unwrap(); // persists the ban
4601
4602        let rogue = Keys::generate();
4603        publish_banlist(&relay, &community, &rogue, &[], 2).await; // unauthorized higher, empty
4604        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4605        assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
4606    }
4607
4608    #[tokio::test]
4609    async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
4610        // CORD-02 §8: create publishes the 13302; a fresh device (community dropped
4611        // locally, the 13302 + genesis still on the relay) rehydrates it on sync.
4612        let (_tmp, _guard, _owner) = init_test_db();
4613        let relay = MemoryRelay::new();
4614        let relays = vec!["wss://r".to_string()];
4615        let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
4616        crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
4617        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
4618
4619        let rehydrated = sync_community_list(&relay, &relays).await.unwrap();
4620        assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
4621        assert_eq!(rehydrated[0].id().0, community.id().0);
4622        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
4623    }
4624
4625    #[tokio::test]
4626    async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
4627        let (_tmp, _guard, _owner) = init_test_db();
4628        let relay = MemoryRelay::new();
4629        let relays = vec!["wss://r".to_string()];
4630        let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
4631        leave_community(&relay, &community).await.unwrap(); // tombstones the 13302 + deletes
4632
4633        let rehydrated = sync_community_list(&relay, &relays).await.unwrap();
4634        assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
4635    }
4636
4637    #[tokio::test]
4638    async fn accepting_the_same_bundle_twice_is_idempotent() {
4639        // A bot restart or a duplicate invite delivery: accepting the SAME bundle
4640        // again must upsert cleanly — same community_id, no duplicate channels, no
4641        // corruption, the keys unchanged.
4642        let (bed, owner, member) = TestBed::new();
4643        bed.swap_to(&owner);
4644        let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
4645        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
4646        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4647        let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4648
4649        bed.swap_to(&member);
4650        let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
4651        let channels_after_first = first.channels.len();
4652        let root_after_first = first.community_root;
4653
4654        // Accept the identical bundle again (restart / redelivery).
4655        let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
4656        assert_eq!(second.id().0, first.id().0, "same community_id");
4657        assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
4658        assert_eq!(second.community_root, root_after_first, "root unchanged");
4659
4660        // The persisted state is a single clean community with the expected channels.
4661        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4662        assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
4663        assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
4664    }
4665
4666    #[tokio::test]
4667    async fn a_severed_member_can_be_unbanned_and_re_admitted() {
4668        // The full moderation HEAL lifecycle: ban (banlist + grant strip + refound)
4669        // severs a member; the owner then unbans + sends a FRESH invite carrying the
4670        // NEW root; the member rejoins at the new epoch and converses again. Proves
4671        // a ban is reversible end-to-end, not a one-way door.
4672        let (bed, owner, member) = TestBed::new();
4673        bed.swap_to(&owner);
4674        let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
4675        let general = community.channels[0].id;
4676        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
4677        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
4678
4679        bed.swap_to(&member);
4680        let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4681        let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
4682        assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
4683
4684        // Owner bans the member (CORD-04 §6 three-removal) → refound severs them.
4685        bed.swap_to(&owner);
4686        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
4687        grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
4688        community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
4689        assert_eq!(community.root_epoch, Epoch(1));
4690        send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
4691
4692        // The member's follow concludes severance (no blob at the new epoch).
4693        bed.swap_to(&member);
4694        let session = SessionGuard::capture();
4695        assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
4696
4697        // Owner unbans + re-invites: build the fresh epoch-1 bundle (accept it
4698        // directly, so the test picks the NEW invite unambiguously rather than an
4699        // arbitrary one of the two pending 3313s).
4700        bed.swap_to(&owner);
4701        set_banlist(&bed.relay, &community, &[]).await.unwrap();
4702        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4703        assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
4704        let fresh_bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4705
4706        // Member accepts the fresh invite → rejoins at epoch 1, reads current + posts.
4707        bed.swap_to(&member);
4708        let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
4709        assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
4710        assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
4711        let seen = texts_in(&bed.relay, &rejoined, &general).await;
4712        assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
4713        send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
4714
4715        bed.swap_to(&owner);
4716        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4717        assert!(
4718            texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
4719            "the re-admitted member converses again at the new epoch"
4720        );
4721        // And they're back in the memberlist.
4722        let members = memberlist(&bed.relay, &community).await.unwrap();
4723        assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
4724    }
4725
4726    #[tokio::test]
4727    async fn dissolution_blocks_a_join() {
4728        // CORD-02 §9: the owner dissolves; a would-be joiner resolves the grave and
4729        // refuses to join.
4730        let (bed, owner, member) = TestBed::new();
4731        bed.swap_to(&owner);
4732        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
4733        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4734        let bundle_json = serde_json::to_string(&bundle).unwrap();
4735        dissolve_community(&bed.relay, &community).await.unwrap();
4736        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
4737
4738        bed.swap_to(&member);
4739        let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
4740        assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
4741    }
4742
4743    #[tokio::test]
4744    async fn only_the_owner_can_dissolve() {
4745        let (bed, owner, member) = TestBed::new();
4746        bed.swap_to(&owner);
4747        let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
4748        bed.swap_to(&member);
4749        assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
4750        assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
4751    }
4752
4753    #[tokio::test]
4754    async fn a_foreign_tombstone_is_not_death() {
4755        // A non-owner sealing the dissolved plane is noise (verify_dissolved is
4756        // owner-gated), so the community is not treated as dead.
4757        let (_tmp, _guard, _owner) = init_test_db();
4758        let relay = MemoryRelay::new();
4759        let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
4760        let rogue = Keys::generate();
4761        let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
4762        let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
4763        relay.publish(&wrap, &community.relays).await.unwrap();
4764        assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
4765    }
4766
4767    #[tokio::test]
4768    async fn a_public_channel_reads_history_across_a_refounding() {
4769        // CORD-03 §3: after a Refounding rolls the base root, a Public channel's
4770        // pre-rotation messages stay readable (the prior epoch's root is archived and
4771        // the read fans out across held epochs).
4772        let (_tmp, _guard, _owner) = init_test_db();
4773        let relay = MemoryRelay::new();
4774        let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
4775        let general = community.channels[0].id;
4776        send_message(&relay, &community, &general, "before the refounding").await.unwrap();
4777
4778        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
4779        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
4780        send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
4781
4782        let texts = texts_in(&relay, &refounded, &general).await;
4783        assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
4784        assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
4785    }
4786
4787    #[tokio::test]
4788    async fn refounding_aborts_when_control_state_is_withheld() {
4789        // B1 coverage gate (CORD-06 §3): a relay serving none of the committed control
4790        // heads must ABORT the Refounding — never silently drop state (e.g. unban a
4791        // member at the new epoch a fresh joiner bootstraps).
4792        let (_tmp, _guard, owner) = init_test_db();
4793        let relay = MemoryRelay::new();
4794        let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
4795        publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
4796        let session = SessionGuard::capture();
4797        follow_control(&relay, &community, &session).await.unwrap(); // seed the banlist floor
4798
4799        // Re-point the held community to an EMPTY relay + save, so the Refounding (which
4800        // reloads fresh state) fetches none of the committed heads.
4801        let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4802        moved.relays = vec!["wss://empty".into()];
4803        crate::db::community::save_community_v2(&moved).unwrap();
4804
4805        let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
4806        assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
4807        assert_eq!(
4808            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
4809            Epoch(0),
4810            "the epoch did NOT advance (zero published state)"
4811        );
4812    }
4813
4814    #[tokio::test]
4815    async fn refounding_rolls_the_root_and_severs_a_removed_member() {
4816        // CORD-06 §3: the owner re-founds, removing a member. The base root rolls, the
4817        // epoch advances, and the removed member's rekey-follow concludes they're cut.
4818        let (bed, owner, member) = TestBed::new();
4819        bed.swap_to(&owner);
4820        let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
4821        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4822        let bundle_json = serde_json::to_string(&bundle).unwrap();
4823        bed.swap_to(&member);
4824        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4825
4826        bed.swap_to(&owner);
4827        let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
4828        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
4829        assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
4830        // The owner still reads the compacted control plane at the new epoch.
4831        let session = SessionGuard::capture();
4832        assert_eq!(
4833            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
4834            Epoch(1),
4835            "the owner committed the new epoch"
4836        );
4837
4838        // The removed member, following rekeys, is severed (no blob in the rotation).
4839        bed.swap_to(&member);
4840        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
4841        assert!(follow.self_removed, "the removed member is cut by the re-founding");
4842    }
4843
4844    #[tokio::test]
4845    async fn only_the_owner_re_founds_even_a_ban_holding_admin_cannot() {
4846        // The receive side (advance_scope) honors ONLY the owner's rotation, so the
4847        // SEND is owner-only — a non-owner BAN-holder's Refounding would fork onto a
4848        // root nobody follows. Owner grants a member BAN; they still can't re-found.
4849        let (bed, owner, member) = TestBed::new();
4850        bed.swap_to(&owner);
4851        let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
4852        let rid = "b0".repeat(32);
4853        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
4854        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
4855        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4856        let bundle_json = serde_json::to_string(&bundle).unwrap();
4857        bed.swap_to(&member);
4858        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4859        assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a non-owner BAN-holder can't re-found");
4860    }
4861
4862    #[tokio::test]
4863    async fn a_retried_refounding_reuses_the_same_root() {
4864        // B1 idempotency: minting for the same (scope, epoch) twice yields the SAME
4865        // root, so a retried Refounding re-delivers one root — never a double-mint fork.
4866        let (_tmp, _guard, _owner) = init_test_db();
4867        let relay = MemoryRelay::new();
4868        let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
4869        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4870        let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
4871        let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
4872        assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
4873    }
4874
4875    #[tokio::test]
4876    async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
4877        // CORD-05 §5: the Registry is the Public/Private source of truth. Minting a
4878        // link publishes it (Public); retiring the last link empties it (Private).
4879        let (_tmp, _guard, _owner) = init_test_db();
4880        let relay = MemoryRelay::new();
4881        let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
4882        assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
4883
4884        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
4885        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
4886        let list = fetch_invite_list(&relay, &community.relays).await.expect("the 13303 list was published");
4887        assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
4888
4889        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
4890        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
4891        assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
4892        let after = fetch_invite_list(&relay, &community.relays).await.unwrap();
4893        assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
4894    }
4895
4896    #[tokio::test]
4897    async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
4898        // The CREATE_INVITE gate: a rogue publishing a registry can't fake Public.
4899        let (_tmp, _guard, owner) = init_test_db();
4900        let relay = MemoryRelay::new();
4901        let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
4902        let rogue = Keys::generate();
4903        // Rogue publishes a registry edition at THEIR coordinate with a fake signer.
4904        let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
4905        let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
4906        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4907        let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
4908        let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
4909        relay.publish(&wrap, &community.relays).await.unwrap();
4910        let _ = owner;
4911        assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
4912    }
4913
4914    #[tokio::test]
4915    async fn full_lifecycle_e2e() {
4916        // The whole stack end to end across two accounts: create -> Public link ->
4917        // owner grants an admin -> member joins + reads history -> admin edits metadata
4918        // (authorized fold) -> owner bans the member (CORD-04 §6: banlist + strip +
4919        // Refounding) -> the banned member is severed AND stays banned across the new
4920        // epoch -> pre-ban history still reads -> owner dissolves -> sealed.
4921        let (bed, owner, member) = TestBed::new();
4922
4923        bed.swap_to(&owner);
4924        let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
4925        let general = community.channels[0].id;
4926        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
4927
4928        // Public link → the community reads Public.
4929        let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
4930        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
4931
4932        // Owner defines + grants an Admin role (MANAGE_METADATA among the bits).
4933        let rid = "aa".repeat(32);
4934        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4935        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
4936
4937        // Member joins from the bundle + reads the owner's message.
4938        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4939        let bundle_json = serde_json::to_string(&bundle).unwrap();
4940        bed.swap_to(&member);
4941        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4942        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
4943        // The admin renames the community.
4944        publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
4945
4946        // Owner follows: the admin's rename folds (authorized).
4947        bed.swap_to(&owner);
4948        let session = SessionGuard::capture();
4949        let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
4950        assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
4951
4952        // Ban the member (the three-removal composition, in order).
4953        set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
4954        grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
4955        let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
4956        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
4957        // The ban survives the Refounding (the banlist head compacted forward).
4958        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
4959        assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
4960        // Pre-ban history still reads across the new epoch.
4961        assert!(
4962            texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
4963            "pre-refounding history stays readable"
4964        );
4965
4966        // The banned member's rekey-follow concludes they're severed.
4967        bed.swap_to(&member);
4968        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
4969        assert!(follow.self_removed, "the banned member is cryptographically cut");
4970
4971        // Owner dissolves → sealed.
4972        bed.swap_to(&owner);
4973        dissolve_community(&bed.relay, &refounded).await.unwrap();
4974        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
4975    }
4976
4977    /// The deep two-account e2e the way a real deployment runs: owner (A) + member (B)
4978    /// over one shared relay, create → channels (public + private) → converse both ways →
4979    /// persist (get_messages-level) → react/edit/delete → moderate (ban/unban) → dissolve.
4980    /// Every account, community, channel, and action is LOGGED (run with --nocapture) so it
4981    /// doubles as a reference transcript and a re-runnable regression.
4982    #[tokio::test]
4983    async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
4984        // A member forges a higher-version role edition at the admin coordinate before a
4985        // refounding. The compaction must carry the AUTHORIZED floor head, not the
4986        // author-blind version tip — else the forgery is re-anchored, honest folders drop
4987        // it, and the admin role vanishes at the new epoch (silent suppression).
4988        let (bed, owner, member) = TestBed::new();
4989        let attacker = Keys::generate();
4990        bed.swap_to(&owner);
4991        let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
4992        let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
4993        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4994        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
4995        // Owner folds → the authorized role/grant heads are floored.
4996        let session = SessionGuard::capture();
4997        follow_control(&bed.relay, &community, &session).await.unwrap();
4998        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
4999
5000        // The attacker (a non-owner) forges v2 of the admin role, chaining onto v1.
5001        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;
5002
5003        // Owner refounds (keeping everyone).
5004        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
5005        assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
5006
5007        // Post-refound, the admin role SURVIVES (the authorized floor head was carried).
5008        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
5009        assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
5010    }
5011
5012    #[tokio::test]
5013    async fn memberlist_survives_a_refounding_via_the_snapshot() {
5014        // A silent survivor (didn't re-post at the new epoch) must stay in the memberlist
5015        // after a refounding — the owner's 3312 snapshot re-seeds them (CORD-02 §5).
5016        let (bed, owner, member) = TestBed::new();
5017        bed.swap_to(&owner);
5018        let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
5019
5020        // Member joins (a Guestbook Join at epoch 0).
5021        let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
5022        bed.swap_to(&member);
5023        accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
5024        bed.swap_to(&owner);
5025        assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
5026
5027        // Owner refounds keeping everyone (removed = []); survivors are snapshotted to epoch 1.
5028        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
5029        assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
5030
5031        // The member is STILL a member at epoch 1 purely via the snapshot (never re-posted).
5032        let members = memberlist(&bed.relay, &refounded).await.unwrap();
5033        assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
5034        assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
5035    }
5036
5037    #[tokio::test]
5038    async fn e2e_two_accounts_channels_converse_moderate() {
5039        use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
5040        use nostr_sdk::prelude::ToBech32;
5041        let (bed, a, b) = TestBed::new();
5042        let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
5043        let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
5044        println!("\n===== Concord v2 deep e2e =====");
5045        println!("[acct] A (owner)  = {a_npub}");
5046        println!("[acct] B (member) = {b_npub}");
5047
5048        // ── A creates the community + a PRIVATE channel + two extra PUBLIC channels ──
5049        bed.swap_to(&a);
5050        let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
5051        let general = community.channels[0].id;
5052        println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
5053
5054        // A PRIVATE channel via the REAL create path: an independent key minted at
5055        // channel-epoch 1, delivered over the rekey plane (A is the only member yet),
5056        // then announced (vsk 2) — later carried to B in the join bundle.
5057        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
5058        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5059        let priv_ch = community.channel(&priv_id).unwrap();
5060        assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
5061        println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
5062
5063        // Two more PUBLIC channels via the real create path.
5064        let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
5065        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5066        let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
5067        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5068        println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
5069        assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
5070
5071        // A talks in a few channels.
5072        let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
5073        send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
5074        send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
5075        println!("[msg] A posted in #general / #announcements / #mods");
5076
5077        // ── A grants B admin, mints a public link, B joins from the bundle ──
5078        let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
5079        publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
5080        publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
5081        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
5082        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
5083        println!("[invite] granted B @admin · minted link {}", link.url);
5084
5085        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.keys.public_key()), None, None)).unwrap();
5086        bed.swap_to(&b);
5087        let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5088        println!("[join] B joined; sees {} channels", b_view.channels.len());
5089        assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
5090        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");
5091        assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
5092        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");
5093        // B folds the control plane (persisting the roster) — the live worker does
5094        // this right after any join; B's admin standing gates B's channel ops below.
5095        let session_b = SessionGuard::capture();
5096        if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
5097            b_view = fresh;
5098        }
5099        println!("[follow] B folded control (roster persisted: B is @admin)");
5100
5101        // ── Conversation both ways + persistence (get_messages-level) ──
5102        send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
5103        send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
5104        println!("[msg] B replied in #general + #mods");
5105        // Persist B's own #general view into the shared store (what sync/live ingest does)
5106        // and confirm it reads back via STATE — get_messages parity.
5107        let my_pk = b.keys.public_key();
5108        let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
5109        for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
5110            let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
5111            if let Some(o) = outcome { persist_chat(&gh, &o).await; }
5112        }
5113        assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
5114        println!("[persist] #general history persisted into the shared events store");
5115
5116        // B (admin) reacts to + the author edits/deletes — the chat-op surface.
5117        send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
5118        bed.swap_to(&a);
5119        let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
5120        send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
5121        let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
5122        send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
5123        println!("[ops] reaction + edit + delete round-tripped");
5124
5125        // ── B creates a channel as admin, A folds it in ──
5126        bed.swap_to(&b);
5127        let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
5128        println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
5129        bed.swap_to(&a);
5130        let session = SessionGuard::capture();
5131        if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
5132            community = updated;
5133        }
5134        assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
5135        println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
5136
5137        // ── A creates a SECOND private channel while B is already a member: B is a
5138        // recipient of the creation delivery, so B keys up from the rekey plane
5139        // (keyless record → cursor walk → blob) with no bundle involved ──
5140        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
5141        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5142        send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
5143        println!("[channel] +private #vault {} (B is a live member — delivery via rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0));
5144        bed.swap_to(&b);
5145        let session_b2 = SessionGuard::capture();
5146        if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
5147            b_view = fresh;
5148        }
5149        let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
5150        assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
5151        let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
5152        b_view = rf.updated.expect("the rekey walk adopts the creation delivery");
5153        let ch = b_view.channel(&vault).expect("still recorded");
5154        assert!(ch.key.is_some() && ch.epoch == Epoch(1), "B adopted the epoch-1 key from the creation crate");
5155        assert!(
5156            texts_in(&bed.relay, &b_view, &vault).await.contains(&"A: vault is open".to_string()),
5157            "B reads the private history with the ADOPTED key"
5158        );
5159        send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.unwrap();
5160        bed.swap_to(&a);
5161        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5162        assert!(
5163            texts_in(&bed.relay, &community, &vault).await.contains(&"B: in the vault".to_string()),
5164            "A reads B's reply on the natively-created private channel"
5165        );
5166        println!("[private] B adopted #vault via rekey plane; two-way private conversation verified");
5167
5168        // ── Members ──
5169        let members = memberlist(&bed.relay, &community).await.unwrap();
5170        let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
5171        assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
5172        println!("[members] {} members: A + B present", members.len());
5173
5174        // ── Moderate: ban B (banlist + strip + refound), verify severance + survival ──
5175        set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
5176        grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
5177        let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
5178        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
5179        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
5180        assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
5181        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");
5182        assert!(
5183            texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
5184            "PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
5185        );
5186        println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
5187        // B concludes it's severed.
5188        bed.swap_to(&b);
5189        let session_b3 = SessionGuard::capture();
5190        assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
5191        println!("[ban] B's rekey-follow: self_removed = true (severed)");
5192
5193        // ── Unban: A lifts the ban ──
5194        bed.swap_to(&a);
5195        set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
5196        let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
5197        assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
5198        println!("[unban] B removed from the banlist (re-invitable)");
5199
5200        // ── Dissolve ──
5201        dissolve_community(&bed.relay, &refounded).await.unwrap();
5202        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
5203        println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
5204    }
5205
5206    /// The same scenario on a REAL relay with TWO throwaway accounts, off by default. It
5207    /// LOGS both nsecs (+ every id) so you can inspect the run and RE-RUN against the same
5208    /// accounts by exporting `VECTOR_E2E_NSEC_A` / `_B`. Set `VECTOR_E2E_LOG=<path>` to also
5209    /// append the transcript to a file, `VECTOR_E2E_RELAY=<url>` to pick the relay.
5210    ///   cargo test -p vector-core -- --ignored --nocapture live_e2e_two_accounts
5211    #[tokio::test]
5212    #[ignore]
5213    async fn live_e2e_two_accounts() {
5214        use crate::community::transport::LiveTransport;
5215        use nostr_sdk::prelude::{ClientBuilder, RelayOptions, ToBech32};
5216
5217        let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
5218        let relays = vec![relay.clone()];
5219        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5220        crate::db::close_database();
5221        crate::db::clear_id_caches();
5222        let tmp = tempfile::tempdir().unwrap();
5223        crate::db::set_app_data_dir(tmp.path().to_path_buf());
5224
5225        // Throwaway (or bring-your-own via env for a re-run against the same accounts).
5226        let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
5227        let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
5228
5229        let log = |line: String| {
5230            println!("{line}");
5231            if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
5232                use std::io::Write;
5233                if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
5234                    let _ = writeln!(f, "{line}");
5235                }
5236            }
5237        };
5238        log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
5239        log(format!("VECTOR_E2E_NSEC_A={}  ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
5240        log(format!("VECTOR_E2E_NSEC_B={}  ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
5241
5242        for k in [&a, &b] {
5243            let npub = k.public_key().to_bech32().unwrap();
5244            std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
5245            crate::db::set_current_account(npub.clone()).unwrap();
5246            crate::db::init_database(&npub).unwrap();
5247        }
5248        // One relay connection: a v2 wrap is pre-signed (ephemeral p-key) and its seal is
5249        // signed by MY_SECRET_KEY, so publishing needs no per-account client signer.
5250        let client = ClientBuilder::new().signer(a.clone()).build();
5251        client.pool().add_relay(relay.as_str(), RelayOptions::default()).await.ok();
5252        client.connect().await;
5253        crate::state::set_nostr_client(client);
5254        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
5255        let become_acct = |k: &Keys| {
5256            let npub = k.public_key().to_bech32().unwrap();
5257            crate::db::set_current_account(npub.clone()).unwrap();
5258            crate::db::init_database(&npub).unwrap();
5259            crate::db::clear_id_caches();
5260            crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
5261            crate::state::set_my_public_key(k.public_key());
5262        };
5263        let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
5264
5265        // A: create + a channel + grant B admin + mint link.
5266        become_acct(&a);
5267        let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
5268        let general = community.channels[0].id;
5269        log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
5270        send_message(&transport, &community, &general, "A: live hello").await.expect("send");
5271        let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
5272        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5273        log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
5274        grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
5275        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
5276        log(format!("[invite] B granted @admin · link {}", link.url));
5277        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.public_key()), None, None)).unwrap();
5278        settle().await;
5279
5280        // B: join + read A's history + reply.
5281        become_acct(&b);
5282        let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
5283        log(format!("[join] B joined; {} channels", b_view.channels.len()));
5284        settle().await;
5285        let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
5286        let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
5287        log(format!("[read] B sees #general: {seen:?}"));
5288        assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
5289        send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
5290
5291        // B posts a NIP-22 kind-1111 THREADED REPLY to A's message (the shape Armada
5292        // sends) directly onto the chat plane — proving the cross-client thread
5293        // RECEIVE path works live, not just in the offline fixture.
5294        let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
5295        let hello_id = hello.event.opened().rumor_id.to_hex();
5296        let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5297        let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
5298        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());
5299        let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
5300        transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
5301        log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
5302        settle().await;
5303
5304        // A reads the thread reply back, rendered inline with A's message as parent.
5305        become_acct(&a);
5306        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5307        let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
5308        let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
5309        if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
5310            assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
5311            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");
5312        } else {
5313            panic!("the 1111 parsed as a Message");
5314        }
5315        log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
5316        become_acct(&b);
5317        settle().await;
5318
5319        // A: create a PRIVATE channel while B is already a member — B is a recipient
5320        // of the creation delivery, so B keys up from the rekey plane over the real
5321        // relay (no bundle involved), then the two converse on it.
5322        become_acct(&a);
5323        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5324        let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
5325        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5326        send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
5327        log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
5328        settle().await;
5329
5330        become_acct(&b);
5331        let session_b = SessionGuard::capture();
5332        let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
5333        if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
5334            b_view = fresh;
5335        }
5336        if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
5337            b_view = fresh;
5338        }
5339        let vch = b_view.channel(&vault).expect("B folded the vault");
5340        assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
5341        let vseen = texts_in(&transport, &b_view, &vault).await;
5342        log(format!("[read] B sees #vault: {vseen:?}"));
5343        assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
5344        send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
5345        settle().await;
5346
5347        become_acct(&a);
5348        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5349        assert!(
5350            texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
5351            "A reads B's private reply"
5352        );
5353        log("[private] two-way #vault conversation over the live relay".to_string());
5354
5355        // A: ban B (three-removal) + dissolve.
5356        set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
5357        grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
5358        let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
5359        log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
5360        settle().await;
5361        dissolve_community(&transport, &refounded).await.expect("dissolve");
5362        log("[dissolve] community sealed".to_string());
5363        log("===== LIVE e2e PASS =====".to_string());
5364    }
5365
5366    #[tokio::test]
5367    async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
5368        // The tombstone rides its own public plane, watched live — an OFFLINE
5369        // member's catch-up must fetch it too, or they follow (and post into) a
5370        // grave forever.
5371        let (bed, owner, member) = TestBed::new();
5372        bed.swap_to(&owner);
5373        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
5374        let general = community.channels[0].id;
5375        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
5376
5377        bed.swap_to(&member);
5378        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
5379        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
5380
5381        // The owner dissolves while the member sleeps.
5382        bed.swap_to(&owner);
5383        dissolve_community(&bed.relay, &community).await.unwrap();
5384
5385        // The member's catch-up learns of the death, seals, and refuses to post.
5386        bed.swap_to(&member);
5387        let session = SessionGuard::capture();
5388        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
5389        assert!(follow.dissolved, "the catch-up surfaces the tombstone");
5390        assert!(!follow.self_removed && follow.updated.is_none());
5391        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
5392        assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
5393        let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
5394        assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
5395        // Subsequent follows take the local fast path — still dissolved, no churn.
5396        let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
5397        assert!(again.dissolved && again.updated.is_none());
5398    }
5399
5400    #[tokio::test]
5401    async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
5402        // Scale stress: MANY private channels, each rotated on every Refounding.
5403        // A member offline across two refoundings must converge on all of them
5404        // (the per-channel rotation fan in refound + the follow's channel×root×step
5405        // loops stay bounded) with every channel's history readable.
5406        const PRIV_CHANNELS: usize = 6;
5407        let (bed, owner, member) = TestBed::new();
5408        bed.swap_to(&owner);
5409        let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
5410        let mut priv_ids = Vec::new();
5411        for i in 0..PRIV_CHANNELS {
5412            let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
5413            community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5414            send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
5415            priv_ids.push(id);
5416        }
5417        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
5418
5419        // Member joins at epoch 0 with all channel keys, then goes offline.
5420        bed.swap_to(&member);
5421        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5422        assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
5423
5424        // Two refoundings (each rotates the base + every private channel).
5425        bed.swap_to(&owner);
5426        for epoch in 1..=2u64 {
5427            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
5428            assert_eq!(community.root_epoch, Epoch(epoch));
5429            for id in &priv_ids {
5430                send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
5431            }
5432        }
5433
5434        // Member returns: bounded follow to quiescence.
5435        bed.swap_to(&member);
5436        let session = SessionGuard::capture();
5437        let mut passes = 0;
5438        loop {
5439            passes += 1;
5440            assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
5441            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5442            let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
5443            assert!(!rk.self_removed);
5444            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5445            let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
5446            if rk.updated.is_none() && ctl.is_none() {
5447                break;
5448            }
5449        }
5450        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5451        assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
5452        // Every private channel converged to the owner's current key + reads all epochs.
5453        for id in &priv_ids {
5454            let mine = caught_up.channel(id).expect("channel survived");
5455            let theirs = community.channel(id).unwrap();
5456            assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
5457            assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
5458            let texts = texts_in(&bed.relay, &caught_up, id).await;
5459            let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
5460            assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
5461            for epoch in 1..=2u64 {
5462                assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
5463            }
5464        }
5465    }
5466
5467    #[tokio::test]
5468    async fn an_offline_member_catches_up_across_three_refoundings() {
5469        // The deep offline-online scenario: a member sleeps through THREE
5470        // Refoundings, per-refound private-channel rotations, a mid-life private
5471        // channel CREATED while they slept, a public channel, a rename, and a
5472        // ban — then returns and converges by follow alone (no rejoin).
5473        use nostr_sdk::prelude::ToBech32;
5474        let (bed, owner, member) = TestBed::new();
5475        bed.swap_to(&owner);
5476        let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
5477        let general = community.channels[0].id;
5478        let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
5479        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5480        send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
5481        send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
5482        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
5483
5484        // Member joins at epoch 0, then goes OFFLINE.
5485        bed.swap_to(&member);
5486        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5487        assert_eq!(member_view.root_epoch, Epoch(0));
5488
5489        // While they sleep, the owner reshapes everything across three epochs.
5490        bed.swap_to(&owner);
5491        let stranger = Keys::generate();
5492        for epoch in 1..=3u64 {
5493            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
5494            assert_eq!(community.root_epoch, Epoch(epoch));
5495            send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
5496            send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
5497        }
5498        let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
5499        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5500        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
5501        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5502        send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
5503        set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
5504        let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
5505        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
5506
5507        // The member RETURNS: rekey+control follow to quiescence (the worker's
5508        // loop, driven explicitly). Bounded — convergence must be fast.
5509        bed.swap_to(&member);
5510        let session = SessionGuard::capture();
5511        let mut passes = 0;
5512        loop {
5513            passes += 1;
5514            assert!(passes <= 6, "catch-up must converge, not churn");
5515            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5516            let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
5517            assert!(!rekeyed.self_removed, "the member was never removed");
5518            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5519            let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
5520            if rekeyed.updated.is_none() && controlled.is_none() {
5521                break;
5522            }
5523        }
5524        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5525
5526        // Base + name converged.
5527        assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
5528        assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
5529        assert_eq!(caught_up.name, "Sleeper Reborn");
5530        // Channels: renamed set incl. the mid-sleep public + private ones.
5531        assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
5532        let m = caught_up.channel(&mods).expect("mods survived");
5533        let owner_mods = community.channel(&mods).unwrap();
5534        assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
5535        assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
5536        let v = caught_up.channel(&vault).expect("vault folded in");
5537        assert_eq!(v.key, community.channel(&vault).unwrap().key, "adopted the mid-sleep private channel's key");
5538        // Banlist survived the compactions.
5539        let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
5540        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
5541        assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
5542        // History reads across EVERY epoch (public via base-root archive, private
5543        // via the per-channel archive built during the walk).
5544        let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
5545        for epoch in 0..=3u64 {
5546            let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
5547            assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
5548        }
5549        let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
5550        for epoch in 0..=3u64 {
5551            let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
5552            assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
5553        }
5554        assert!(texts_in(&bed.relay, &caught_up, &vault).await.contains(&"epoch3: vault opened".to_string()));
5555        // And the member can still speak.
5556        send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
5557        bed.swap_to(&owner);
5558        assert!(
5559            texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
5560            "the caught-up member converses at the new epoch ({})",
5561            member.keys.public_key().to_bech32().unwrap()
5562        );
5563    }
5564
5565    /// Seal `n` messages onto a community's #general, one per second starting at
5566    /// `base_secs` (distinct wrap seconds so relay-side `until` paging engages).
5567    async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
5568        let general = community.channels[0].id;
5569        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
5570        for i in 0..n {
5571            let at = base_secs + i as u64;
5572            let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
5573            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
5574            relay.publish(&wrap, &community.relays).await.unwrap();
5575        }
5576    }
5577
5578    #[tokio::test]
5579    async fn the_history_walk_pages_past_a_multi_page_burst() {
5580        // A bot offline through 120 messages must catch ALL of them, not the
5581        // newest page — the v1 sync-gap class, closed by until-paging.
5582        let (_tmp, _guard, owner) = init_test_db();
5583        let relay = MemoryRelay::new();
5584        let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
5585        let general = community.channels[0].id;
5586        flood_general(&relay, &community, &owner, 120, 10_000).await;
5587
5588        let all = fetch_channel_history(&relay, &community, &general, 50, 8, |_| true).await.unwrap();
5589        assert_eq!(all.len(), 120, "the walk pages the whole burst");
5590        // Oldest→newest, no duplicates.
5591        let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
5592        assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
5593        assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
5594        let unique: std::collections::HashSet<&String> = contents.iter().collect();
5595        assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
5596
5597        // The single-page fetch stays a single page.
5598        let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
5599        assert_eq!(one.len(), 50, "fetch_channel is one newest page");
5600        assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
5601    }
5602
5603    #[tokio::test]
5604    async fn the_history_walk_stops_when_the_caller_is_caught_up() {
5605        let (_tmp, _guard, owner) = init_test_db();
5606        let relay = MemoryRelay::new();
5607        let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
5608        let general = community.channels[0].id;
5609        flood_general(&relay, &community, &owner, 120, 10_000).await;
5610
5611        // The caller says "I hold everything" after the first page — no deeper fetch.
5612        let mut pages = 0usize;
5613        let got = fetch_channel_history(&relay, &community, &general, 50, 8, |_| {
5614            pages += 1;
5615            false
5616        })
5617        .await
5618        .unwrap();
5619        assert_eq!(pages, 1, "the early stop is consulted once");
5620        assert_eq!(got.len(), 50, "only the newest page is fetched");
5621        assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
5622    }
5623
5624    #[tokio::test]
5625    async fn a_same_second_history_wall_terminates_instead_of_looping() {
5626        // 60 messages in ONE second with a 25-wrap page: a second-granular
5627        // `until` can never page past the wall — the walk must step over it
5628        // (bounded loss, logged) rather than spin.
5629        let (_tmp, _guard, owner) = init_test_db();
5630        let relay = MemoryRelay::new();
5631        let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
5632        let general = community.channels[0].id;
5633        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
5634        for i in 0..60usize {
5635            let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
5636            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
5637            relay.publish(&wrap, &community.relays).await.unwrap();
5638        }
5639        let got = fetch_channel_history(&relay, &community, &general, 25, 8, |_| true).await.unwrap();
5640        assert!(got.len() >= 25, "at least the relay page is read");
5641        assert!(got.len() <= 60, "sane bound");
5642        // Termination is the assertion: reaching here means the wall didn't loop.
5643    }
5644
5645    #[tokio::test]
5646    async fn a_grant_revoke_survives_a_withholding_relay() {
5647        // Floor persistence on the delegation plane: after the owner revokes an admin,
5648        // a relay serving only the OLD (still owner-signed) grant can't resurrect it.
5649        let (_tmp, _guard, owner) = init_test_db();
5650        let relay = MemoryRelay::new();
5651        let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
5652        let admin = Keys::generate();
5653        let rid = "d4".repeat(32);
5654        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
5655        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
5656        let session = SessionGuard::capture();
5657        follow_control(&relay, &community, &session).await.unwrap(); // seed floors incl. the grant at v1
5658        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke → grant floor v2
5659        follow_control(&relay, &community, &session).await.unwrap();
5660
5661        // A stale relay serves only the grant prefix (v1, the live grant).
5662        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
5663        let mut stale = community.clone();
5664        stale.relays = vec!["wss://stale".into()];
5665        let floors = load_floors(&community);
5666        let editions = fetch_control(&relay, &stale).await;
5667        let authority = fold_authority(&stale, &editions, &floors);
5668        assert!(
5669            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
5670            "the persisted grant floor refuses the rolled-back (re-granted) view"
5671        );
5672    }
5673
5674    /// Load the current-epoch floors for a community (test mirror of follow_control).
5675    fn load_floors(community: &CommunityV2) -> Floors {
5676        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5677        crate::db::community::get_all_edition_heads_full(&cid_hex)
5678            .unwrap_or_default()
5679            .into_iter()
5680            .filter(|(_, f)| f.0 == community.root_epoch.0)
5681            .map(|(e, f)| (e, (f.1, f.2, f.3)))
5682            .collect()
5683    }
5684
5685    /// Fetch + open every control edition at a community's control plane (test helper).
5686    async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
5687        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5688        let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
5689        relay
5690            .fetch(&q, &community.relays)
5691            .await
5692            .unwrap_or_default()
5693            .iter()
5694            .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
5695            .collect()
5696    }
5697
5698    #[tokio::test]
5699    async fn follow_control_is_a_noop_on_a_freshly_created_community() {
5700        let (_tmp, _guard, _owner) = init_test_db();
5701        let relay = MemoryRelay::new();
5702        let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
5703        let session = SessionGuard::capture();
5704        // Only the genesis editions exist; folding them reproduces the held view.
5705        assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
5706    }
5707
5708    #[tokio::test]
5709    async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
5710        let (_tmp, _guard, owner) = init_test_db();
5711        let relay = MemoryRelay::new();
5712        let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
5713        let new_id = ChannelId([0x5a; 32]);
5714        publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
5715
5716        let session = SessionGuard::capture();
5717        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
5718        assert_eq!(updated.channels.len(), 2);
5719        let added = updated.channel(&new_id).expect("the new channel folded in");
5720        assert_eq!(added.name, "announcements");
5721        assert!(!added.private);
5722        assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
5723
5724        // The new channel is now in the realtime author-set (it would be subscribed).
5725        let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
5726        let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
5727        assert!(authors.contains(&addr), "the added channel joins the live subscription");
5728
5729        // Persisted: a reload sees it too.
5730        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5731        assert!(reloaded.channel(&new_id).is_some());
5732    }
5733
5734    #[tokio::test]
5735    async fn follow_control_renames_the_community_and_an_existing_channel() {
5736        let (_tmp, _guard, owner) = init_test_db();
5737        let relay = MemoryRelay::new();
5738        let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
5739        let general = community.channels[0].id;
5740        // A v2 metadata edition renames the community; a v2 channel edition renames #general.
5741        publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
5742        publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
5743
5744        let session = SessionGuard::capture();
5745        let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
5746        assert_eq!(updated.name, "New Name");
5747        assert_eq!(updated.channel(&general).unwrap().name, "lobby");
5748        assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
5749    }
5750
5751    #[tokio::test]
5752    async fn follow_control_deletes_a_channel() {
5753        let (_tmp, _guard, owner) = init_test_db();
5754        let relay = MemoryRelay::new();
5755        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
5756        let extra = ChannelId([0x77; 32]);
5757        let session = SessionGuard::capture();
5758
5759        // The channel is first added and folded into the held view.
5760        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
5761        let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
5762        assert!(with_extra.channel(&extra).is_some());
5763
5764        // Then it's tombstoned — the delete (higher version) folds the held one back out.
5765        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
5766        let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
5767        assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
5768        assert_eq!(updated.channels.len(), 1, "only #general remains");
5769    }
5770
5771    /// Re-inject only the OLD prefix (every edition at/below `max_version`) of a
5772    /// community's control plane onto a second relay URL — the withholding-relay
5773    /// simulation: everything it serves is genuinely owner-signed, just stale.
5774    async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
5775        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5776        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
5777        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
5778        for w in &wraps {
5779            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
5780                if ed.version <= max_version {
5781                    relay.inject(w, &[stale_relay.to_string()]);
5782                }
5783            }
5784        }
5785    }
5786
5787    #[tokio::test]
5788    async fn a_withholding_relay_cannot_roll_back_a_rename() {
5789        // W2 persisted floor: after adopting the owner's v2 rename, a relay serving
5790        // only the (owner-signed) v1 genesis must not revert the held name.
5791        let (_tmp, _guard, owner) = init_test_db();
5792        let relay = MemoryRelay::new();
5793        let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
5794        publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
5795
5796        let session = SessionGuard::capture();
5797        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
5798        assert_eq!(updated.name, "Renamed");
5799
5800        // The stale relay holds only the genesis prefix; point the follow at it.
5801        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
5802        let mut stale_view = updated.clone();
5803        stale_view.relays = vec!["wss://stale".into()];
5804        assert!(
5805            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
5806            "a stale-only relay must not change the held view"
5807        );
5808        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5809        assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
5810    }
5811
5812    #[tokio::test]
5813    async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
5814        let (_tmp, _guard, owner) = init_test_db();
5815        let relay = MemoryRelay::new();
5816        let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
5817        let extra = ChannelId([0x44; 32]);
5818        let session = SessionGuard::capture();
5819
5820        // A same-content metadata edit: no visible change (None), but the floor must
5821        // still advance to v2 (so the genesis metadata can't re-present below).
5822        publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
5823        assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
5824
5825        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
5826        let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
5827        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
5828        let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
5829        assert!(pruned.channel(&extra).is_none());
5830
5831        // The stale relay serves the add (v1) but withholds the delete (v2).
5832        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
5833        let mut stale_view = pruned.clone();
5834        stale_view.relays = vec!["wss://stale".into()];
5835        assert!(
5836            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
5837            "the withheld delete must not resurrect the channel"
5838        );
5839        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5840        assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
5841    }
5842
5843    #[tokio::test]
5844    async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
5845        // The Armada-convergence carve-out: a Refounding compacts the chain and
5846        // re-wraps a detached head at the NEW epoch's control plane. The old epoch's
5847        // floor must not block it — epoch-filtering makes the entity bootstrap.
5848        let (_tmp, _guard, owner) = init_test_db();
5849        let relay = MemoryRelay::new();
5850        let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
5851        let session = SessionGuard::capture();
5852        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
5853        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
5854        assert_eq!(updated.name, "Edited");
5855
5856        // Refounding lands (epoch bump saved by the rekey path); the compacted head
5857        // arrives DETACHED (high version, no prev) on the new epoch's plane.
5858        let mut refounded = updated.clone();
5859        refounded.root_epoch = crate::community::Epoch(1);
5860        crate::db::community::save_community_v2(&refounded).unwrap();
5861        publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
5862
5863        let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
5864        assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
5865        // The persisted floor is stamped with the epoch the FOLD ran under.
5866        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5867        let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
5868        assert!(
5869            heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
5870            "the adopted head carries the fold's epoch + version"
5871        );
5872    }
5873
5874    #[tokio::test]
5875    async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
5876        // Two owner-signed editions at the SAME version (publish retry / two owner
5877        // devices): every client must land on the lower-inner-id winner. A hash-strict
5878        // floor would wedge here forever while Armada converges — the floor must
5879        // CONVERGE instead (the v1 decide() rule).
5880        let (_tmp, _guard, owner) = init_test_db();
5881        let relay = MemoryRelay::new();
5882        let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
5883        let session = SessionGuard::capture();
5884        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5885        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
5886
5887        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
5888        let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
5889        assert_eq!(ours.name, "Ours");
5890
5891        // Our committed v2 edition's tiebreak id.
5892        let our_inner = {
5893            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
5894            let wraps = relay.fetch(&q, &community.relays).await.unwrap();
5895            wraps
5896                .iter()
5897                .find_map(|w| {
5898                    control::open_control_edition(w, &group)
5899                        .ok()
5900                        .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
5901                        .map(|(ed, _)| ed.inner_id)
5902                })
5903                .unwrap()
5904        };
5905
5906        // Craft the concurrent fork so it WINS the deterministic tiebreak (vary the
5907        // authored timestamp until its inner id is lower).
5908        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
5909        let content = serde_json::to_string(&meta).unwrap();
5910        let mut ts = 2_000u64;
5911        let fork_wrap = loop {
5912            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
5913            let inner = rumor.id.unwrap().to_bytes();
5914            if inner < our_inner {
5915                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
5916            }
5917            ts += 1;
5918        };
5919        relay.publish(&fork_wrap, &community.relays).await.unwrap();
5920
5921        let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
5922        assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
5923        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5924        let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
5925        assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
5926    }
5927
5928    #[tokio::test]
5929    async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
5930        // v2 chains to the floor; v4 arrives but its v3 link is withheld. The
5931        // chain-verified prefix (v2) applies NOW — refuse-downgrade holds for it —
5932        // while the detached v4 waits. When v3 lands, the chain heals to v4.
5933        let (_tmp, _guard, owner) = init_test_db();
5934        let relay = MemoryRelay::new();
5935        let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
5936        let session = SessionGuard::capture();
5937        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5938
5939        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
5940        let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
5941
5942        // Craft v3 (held back) and v4 (published, chained to the withheld v3).
5943        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
5944        let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
5945        let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
5946        let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
5947        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
5948        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
5949        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
5950        relay.publish(&w4, &community.relays).await.unwrap();
5951
5952        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
5953        assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
5954
5955        relay.publish(&w3, &community.relays).await.unwrap();
5956        let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
5957        assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
5958    }
5959
5960    #[tokio::test]
5961    async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
5962        // The held floor is v2; the owner publishes v3, then a flood of foreign junk
5963        // wraps fills the newest window, then v4. Page 1 sees only v4 (detached →
5964        // gapped); paging older must recover v3 (and the floor link) and heal to v4.
5965        let (_tmp, _guard, owner) = init_test_db();
5966        let relay = MemoryRelay::new();
5967        let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
5968        let session = SessionGuard::capture();
5969        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5970
5971        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
5972        let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
5973        publish_community_meta(&relay, &base, &owner, "Three", 3).await; // ts 1_000 (old)
5974        let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
5975
5976        // Rogue flood occupying the newest window (sealed to the control plane, but
5977        // non-owner — the authority gate drops them; they only crowd the page).
5978        let rogue = Keys::generate();
5979        for i in 0..(FOLLOW_PAGE as u64 - 1) {
5980            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
5981            let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
5982            relay.publish(&w, &community.relays).await.unwrap();
5983        }
5984        // v4 chained to the real v3 (crafted directly: the flood also blinds the
5985        // helper's own newest-window head lookup), timestamped newest of all.
5986        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
5987        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
5988        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
5989        relay.publish(&w4, &community.relays).await.unwrap();
5990
5991        let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
5992        assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
5993    }
5994
5995    #[tokio::test]
5996    async fn a_follow_after_delete_does_not_resurrect_the_community() {
5997        // A leave/delete racing an in-flight follow: the follow must not re-insert
5998        // the community row or floor rows past delete_community's wipe.
5999        let (_tmp, _guard, owner) = init_test_db();
6000        let relay = MemoryRelay::new();
6001        let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
6002        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
6003        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6004        crate::db::community::delete_community(&cid_hex).unwrap();
6005
6006        let session = SessionGuard::capture();
6007        assert!(
6008            follow_control(&relay, &community, &session).await.unwrap().is_none(),
6009            "a follow racing a delete is a no-op"
6010        );
6011        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
6012        assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
6013    }
6014
6015    #[tokio::test]
6016    async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
6017        // The rekey sibling of the follow_control guard: an owner rotation adopted
6018        // mid-race must not upsert the community row back after a leave/delete.
6019        let (_tmp, _guard, owner) = init_test_db();
6020        let relay = MemoryRelay::new();
6021        let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
6022        let new_root = [0xB2; 32];
6023        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
6024        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6025        crate::db::community::delete_community(&cid_hex).unwrap();
6026
6027        let session = SessionGuard::capture();
6028        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6029        assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
6030        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
6031    }
6032
6033    #[tokio::test]
6034    async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
6035        // {v1, v3} on the relays with v2 lost at publish time (a rate-limiting relay
6036        // that still ACKed): the genesis anchors, so an anchored-prefix-first fold
6037        // would take v1 and SEED the joiner's floor there — pinning them below the
6038        // head Armada shows, forever. A joiner (floor 0) must bootstrap v3.
6039        let (bed, owner, member) = TestBed::new();
6040        bed.swap_to(&owner);
6041        let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
6042        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6043        let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
6044
6045        // v2 is crafted but NEVER published; v3 chains to it and is published.
6046        let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
6047        let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
6048        let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
6049        let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
6050        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
6051        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);
6052        let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
6053        bed.relay.publish(&w3, &community.relays).await.unwrap();
6054
6055        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
6056        let bundle_json = serde_json::to_string(&bundle).unwrap();
6057        bed.swap_to(&member);
6058        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
6059        assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
6060        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
6061        let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
6062        assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
6063    }
6064
6065    #[tokio::test]
6066    async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
6067        // The refusal half of fork convergence: a relay withholding OUR committed
6068        // floor edition while serving only a same-version fork with a HIGHER inner
6069        // id must be treated as withholding — held state and floor unchanged.
6070        let (_tmp, _guard, owner) = init_test_db();
6071        let relay = MemoryRelay::new();
6072        let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
6073        let session = SessionGuard::capture();
6074        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6075        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
6076
6077        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
6078        let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
6079        assert_eq!(ours.name, "Ours");
6080        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6081        let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
6082        let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
6083
6084        // Grind the fork to LOSE the tiebreak (higher inner id), then serve it —
6085        // with the genesis but WITHOUT our v2 — from a withholding relay.
6086        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
6087        let content = serde_json::to_string(&meta).unwrap();
6088        let mut ts = 5_000u64;
6089        let fork_wrap = loop {
6090            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
6091            if rumor.id.unwrap().to_bytes() > our_inner {
6092                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
6093            }
6094            ts += 1;
6095        };
6096        inject_stale_prefix(&relay, &community, 1, "wss://stale").await; // genesis only
6097        relay.inject(&fork_wrap, &["wss://stale".to_string()]);
6098        let mut stale_view = ours.clone();
6099        stale_view.relays = vec!["wss://stale".into()];
6100
6101        assert!(
6102            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
6103            "a losing fork served without our floor edition changes nothing"
6104        );
6105        let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
6106        assert_eq!(held_after, held_before, "the floor row is untouched");
6107        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6108        assert_eq!(held.name, "Ours", "the held state is untouched");
6109    }
6110
6111    #[tokio::test]
6112    async fn follow_control_ignores_a_non_owner_edition() {
6113        // A member holds the community_root, so they CAN seal a control edition —
6114        // but they aren't the owner, so the authority gate drops it (first cut:
6115        // owner-only). The rogue channel must never appear.
6116        let (_tmp, _guard, _owner) = init_test_db();
6117        let relay = MemoryRelay::new();
6118        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
6119        let rogue = Keys::generate();
6120        let rogue_id = ChannelId([0x99; 32]);
6121        publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
6122
6123        let session = SessionGuard::capture();
6124        assert!(
6125            follow_control(&relay, &community, &session).await.unwrap().is_none(),
6126            "a non-owner control edition is not folded"
6127        );
6128    }
6129
6130    #[tokio::test]
6131    async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
6132        // A Private channel's key rides the rekey plane, not the control edition —
6133        // control-follow records it KEYLESS (epoch 0, the rekey-scan cursor), and
6134        // every read/send path refuses it until the key lands (never the root plane).
6135        let (_tmp, _guard, owner) = init_test_db();
6136        let relay = MemoryRelay::new();
6137        let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
6138        let priv_id = ChannelId([0x33; 32]);
6139        publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
6140
6141        let session = SessionGuard::capture();
6142        let updated = follow_control(&relay, &community, &session)
6143            .await
6144            .unwrap()
6145            .expect("the keyless record is a change");
6146        let ch = updated.channel(&priv_id).expect("the private channel is recorded");
6147        assert!(ch.private && ch.key.is_none(), "recorded keyless");
6148        assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
6149        assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
6150        assert!(
6151            fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
6152            "a keyless fetch returns empty (and never queries the root plane)"
6153        );
6154        assert!(
6155            send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
6156            "a keyless send refuses"
6157        );
6158        // The keyless record round-trips (the stored placeholder never surfaces
6159        // as a real key).
6160        let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
6161        let rch = reloaded.channel(&priv_id).unwrap();
6162        assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
6163        // And a bundle minted while keyless never carries the placeholder.
6164        let bundle = bundle_of(&reloaded, None, None, None);
6165        assert!(
6166            !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
6167            "an ungrantable keyless channel stays out of invite bundles"
6168        );
6169    }
6170
6171    // ── Live rekey-follow ────────────────────────────────────────────────────
6172
6173    /// Publish an owner-grammar base rotation (Refounding) delivering `new_root`
6174    /// to each recipient. `rotator` is the seal signer (owner for a legit rotation,
6175    /// a stranger for the authority test); `prev_key` is the root it claims to
6176    /// extend (mismatch → a fork).
6177    async fn publish_base_rotation(
6178        relay: &MemoryRelay,
6179        community: &CommunityV2,
6180        rotator: &Keys,
6181        recipients: &[PublicKey],
6182        new_root: &[u8; 32],
6183        prev_key: &[u8; 32],
6184    ) {
6185        let new_epoch = Epoch(community.root_epoch.0 + 1);
6186        let prev_epoch = community.root_epoch;
6187        let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
6188        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
6189        let blobs: Vec<_> = recipients
6190            .iter()
6191            .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
6192            .collect();
6193        let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000).unwrap();
6194        for e in &events {
6195            relay.publish(e, &community.relays).await.unwrap();
6196        }
6197    }
6198
6199    /// Attach a Private channel (key + epoch) to a held community and persist it.
6200    fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
6201        community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
6202        crate::db::community::save_community_v2(community).unwrap();
6203    }
6204
6205    #[tokio::test]
6206    async fn follow_rekeys_is_a_noop_without_rotations() {
6207        let (_tmp, _guard, _owner) = init_test_db();
6208        let relay = MemoryRelay::new();
6209        let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
6210        let session = SessionGuard::capture();
6211        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6212        assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
6213    }
6214
6215    #[tokio::test]
6216    async fn follow_rekeys_adopts_an_owner_base_rotation() {
6217        let (_tmp, _guard, owner) = init_test_db();
6218        let relay = MemoryRelay::new();
6219        let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
6220        let new_root = [0xB1; 32];
6221        // Owner rotates the base to epoch 1, delivering the new root to me.
6222        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
6223
6224        let session = SessionGuard::capture();
6225        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
6226        assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
6227        assert_eq!(updated.community_root, new_root, "adopted the fresh root");
6228        // The public channel now reads under the NEW root/epoch (its address moved).
6229        let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
6230        let general = updated.channels[0].id;
6231        let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
6232        assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
6233    }
6234
6235    #[tokio::test]
6236    async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
6237        let (_tmp, _guard, owner) = init_test_db();
6238        let relay = MemoryRelay::new();
6239        let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
6240        let priv_id = ChannelId([0x33; 32]);
6241        add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
6242
6243        // Owner rotates the private channel to epoch 1 with a fresh key, delivered to me.
6244        let new_key = [0x55; 32];
6245        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
6246        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
6247        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();
6248        let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000).unwrap();
6249        for e in &events {
6250            relay.publish(e, &community.relays).await.unwrap();
6251        }
6252
6253        let session = SessionGuard::capture();
6254        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
6255        let ch = updated.channel(&priv_id).unwrap();
6256        assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
6257        assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
6258        assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
6259    }
6260
6261    #[tokio::test]
6262    async fn follow_rekeys_ignores_a_non_owner_rotation() {
6263        // A member holds the community_root, so they can derive the rekey group key
6264        // and mint a rotation — but they aren't the owner, so it's not adopted.
6265        let (_tmp, _guard, _owner) = init_test_db();
6266        let relay = MemoryRelay::new();
6267        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
6268        let rogue = Keys::generate();
6269        publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
6270
6271        let session = SessionGuard::capture();
6272        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6273        assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
6274    }
6275
6276    #[tokio::test]
6277    async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
6278        // A rotation whose prevcommit doesn't match the key I hold is a fork, not an
6279        // extension — never adopted (would splice me onto an unrelated chain).
6280        let (_tmp, _guard, owner) = init_test_db();
6281        let relay = MemoryRelay::new();
6282        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
6283        // prev_key ≠ the real community_root → the continuity check reads Fork.
6284        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
6285
6286        let session = SessionGuard::capture();
6287        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6288        assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
6289    }
6290
6291    #[tokio::test]
6292    async fn follow_rekeys_holds_on_an_incomplete_rotation() {
6293        // A 2-chunk rotation with only chunk 1 present can never conclude — not an
6294        // adoption, and crucially NOT a removal (a missing chunk might carry my blob).
6295        let (_tmp, _guard, owner) = init_test_db();
6296        let relay = MemoryRelay::new();
6297        let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
6298        let new_epoch = Epoch(1);
6299        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
6300        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
6301        // Chunk 1 of a declared 2, carrying someone else's blob (not mine).
6302        let other = Keys::generate();
6303        let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
6304        let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000).unwrap();
6305        let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
6306        relay.publish(&wrap, &community.relays).await.unwrap();
6307
6308        let session = SessionGuard::capture();
6309        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6310        assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
6311    }
6312
6313    #[tokio::test]
6314    async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
6315        // Realistic two-actor removal: the owner Refounds the base and delivers the
6316        // new root to a THIRD party, not the member — a complete rotation with no
6317        // blob for the member is a removal.
6318        let (bed, owner, member) = TestBed::new();
6319        bed.swap_to(&owner);
6320        let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
6321        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6322
6323        bed.swap_to(&member);
6324        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6325        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6326
6327        // Owner rotates, delivering only to a stranger (the member is dropped).
6328        bed.swap_to(&owner);
6329        let stranger = Keys::generate();
6330        publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
6331
6332        // The member's follow concludes removal (a complete rotation without their blob).
6333        bed.swap_to(&member);
6334        let session = SessionGuard::capture();
6335        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
6336        assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
6337        assert!(follow.updated.is_none(), "a removed member adopts nothing");
6338    }
6339
6340    #[tokio::test]
6341    async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
6342        // PROTO-B2 regression: a Refounding's channel rekeys ride the PRIOR root
6343        // (CORD-06 §3). A follower who adopted the BASE first (the live window:
6344        // the base crate landed and was walked before the channel crates) must
6345        // still find them — the lookup fans across the archived roots, not just
6346        // the current one.
6347        let (_tmp, _guard, owner) = init_test_db();
6348        let relay = MemoryRelay::new();
6349        let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
6350        let root0 = community.community_root;
6351        let priv_id = ChannelId([0x33; 32]);
6352        let key1 = [0x44; 32];
6353        add_private_channel(&mut community, priv_id, key1, Epoch(1));
6354
6355        // The refounder's channel rekey (1 → 2), sealed + addressed under the PRIOR
6356        // root (root0), delivering the fresh key to me.
6357        let key2 = [0x55; 32];
6358        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6359        let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
6360        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();
6361        for e in rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &prev_commit, &[blob], 2_000).unwrap() {
6362            relay.publish(&e, &community.relays).await.unwrap();
6363        }
6364
6365        // Simulate the base having ALREADY advanced (the stranding order): the head
6366        // moved to a fresh root while root0 sits in the epoch-key archive (where
6367        // genesis put it).
6368        community.community_root = [0xB7; 32];
6369        community.root_epoch = Epoch(1);
6370        crate::db::community::save_community_v2(&community).unwrap();
6371
6372        let session = SessionGuard::capture();
6373        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
6374        let ch = updated.channel(&priv_id).unwrap();
6375        assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
6376        assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
6377    }
6378
6379    #[tokio::test]
6380    async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
6381        // A keyless private channel (announced by vsk-2, key not yet held) has no
6382        // chain, so its epoch is a scan cursor: a complete rotation that excludes
6383        // us advances the cursor (never a removal — we were never in); a later
6384        // rotation that includes us is the entry point.
6385        let (_tmp, _guard, owner) = init_test_db();
6386        let relay = MemoryRelay::new();
6387        let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
6388        let priv_id = ChannelId([0x66; 32]);
6389        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() });
6390        crate::db::community::save_community_v2(&community).unwrap();
6391        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6392        assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
6393
6394        // Epoch 1: the creation delivery went to a stranger only (pre-dates us).
6395        let stranger = Keys::generate();
6396        let key1 = [0x71; 32];
6397        let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
6398        let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
6399        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();
6400        for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000).unwrap() {
6401            relay.publish(&e, &community.relays).await.unwrap();
6402        }
6403        // Epoch 2: a later rotation includes ME (e.g. a removal-forced re-mint whose
6404        // recipient set is the CURRENT members).
6405        let key2 = [0x72; 32];
6406        let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6407        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
6408        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();
6409        for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100).unwrap() {
6410            relay.publish(&e, &community.relays).await.unwrap();
6411        }
6412
6413        // ONE follow: the cursor walks 0→1 (excluded, still keyless) and 1→2 (my
6414        // blob — adopt), because each real step re-loops.
6415        let session = SessionGuard::capture();
6416        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
6417        let ch = updated.channel(&priv_id).unwrap();
6418        assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
6419        assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
6420    }
6421
6422    #[tokio::test]
6423    async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
6424        // CORD-06 §Authority: a CHANNEL rekey is honored from the owner or a
6425        // MANAGE_CHANNELS holder under the persisted roster — so an admin-run
6426        // rotation keys members up; a mere keyholder's forgery never does.
6427        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6428        let (_tmp, _guard, _owner) = init_test_db();
6429        let relay = MemoryRelay::new();
6430        let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
6431        let priv_id = ChannelId([0x88; 32]);
6432        let key1 = [0x91; 32];
6433        add_private_channel(&mut community, priv_id, key1, Epoch(1));
6434
6435        // Persist a roster granting `admin` the Admin role (MANAGE_CHANNELS ⊂ ADMIN_ALL).
6436        let admin = Keys::generate();
6437        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6438        let role = Role::admin("aa".repeat(32));
6439        let roster = CommunityRoles {
6440            roles: vec![role.clone()],
6441            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
6442        };
6443        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
6444
6445        // The ADMIN rotates the channel 1 → 2, delivering to me: adopted.
6446        let key2 = [0x92; 32];
6447        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6448        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
6449        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
6450        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
6451        for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000).unwrap() {
6452            relay.publish(&e, &community.relays).await.unwrap();
6453        }
6454        let session = SessionGuard::capture();
6455        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
6456        assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
6457
6458        // A STRANGER (keyholder, no roster standing) rotates 2 → 3: refused.
6459        let rogue = Keys::generate();
6460        let key3 = [0x93; 32];
6461        let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
6462        let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
6463        let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
6464        for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100).unwrap() {
6465            relay.publish(&e, &updated.relays).await.unwrap();
6466        }
6467        let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
6468        assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
6469    }
6470
6471    #[tokio::test]
6472    async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
6473        // CORD-06 §Authority: the Rotator must strictly OUTRANK every removed
6474        // target. An equal-rank bit-holder's complete rotation that skips my blob
6475        // must read Stay (my record survives); the OWNER's reads Removed. Needs a
6476        // two-account bed: the follower must be a NON-owner admin.
6477        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6478        let (bed, owner, member) = TestBed::new();
6479        bed.swap_to(&owner);
6480        let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
6481
6482        // The MEMBER's device: holds the community + the private channel, with a
6483        // persisted roster granting the member AND a peer the same Admin role.
6484        bed.swap_to(&member);
6485        let mut held = community.clone();
6486        let priv_id = ChannelId([0xAB; 32]);
6487        let key1 = [0xA1; 32];
6488        add_private_channel(&mut held, priv_id, key1, Epoch(1));
6489        let peer = Keys::generate();
6490        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6491        let role = Role::admin("bb".repeat(32));
6492        let roster = CommunityRoles {
6493            roles: vec![role.clone()],
6494            grants: vec![
6495                MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
6496                MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
6497            ],
6498        };
6499        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
6500
6501        // The equal-rank PEER rotates 1 → 2 delivering only to themselves.
6502        let key2 = [0xA2; 32];
6503        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6504        let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
6505        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();
6506        for e in rekey::build_rekey_chunks_local(&peer, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[pb], 2_000).unwrap() {
6507            bed.relay.publish(&e, &held.relays).await.unwrap();
6508        }
6509        let session = SessionGuard::capture();
6510        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
6511        assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
6512        let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
6513        assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
6514
6515        // The OWNER's rotation excluding me IS a removal (owner outranks everyone).
6516        let key3 = [0xA3; 32];
6517        let stranger = Keys::generate();
6518        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();
6519        for e in rekey::build_rekey_chunks_local(&owner.keys, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[ob], 2_100).unwrap() {
6520            bed.relay.publish(&e, &held.relays).await.unwrap();
6521        }
6522        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
6523        let updated = follow.updated.expect("the owner's removal folds");
6524        assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
6525    }
6526
6527    #[tokio::test]
6528    async fn converting_a_public_channel_to_private_is_refused() {
6529        // The conversion (CORD-03 §2) is a key rotation this build doesn't mint yet:
6530        // the producer refuses the flag flip, so no reader is left unkeyable.
6531        let (_tmp, _guard, _owner) = init_test_db();
6532        let relay = MemoryRelay::new();
6533        let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
6534        let general = community.channels[0].id;
6535        let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
6536        let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
6537        assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
6538        // A rename of the same public channel still works.
6539        let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
6540        edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
6541    }
6542
6543    /// Publish a 13302 (signed by `me`) carrying a leave tombstone for `cid_hex` at
6544    /// `removed_at` — simulating a sibling device having left that community.
6545    async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
6546        let doc = super::super::list::CommunityList {
6547            entries: vec![],
6548            tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
6549            extra: Default::default(),
6550        };
6551        let event = super::super::list::build_list_event(me, &doc).unwrap();
6552        relay.publish(&event, relays).await.unwrap();
6553    }
6554
6555    #[tokio::test]
6556    async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
6557        // W1 (send side): a sibling device left X (a remote tombstone). Joining a
6558        // DIFFERENT community must not re-add X to the 13302 with added_at=now,
6559        // which would silently undo the leave everywhere.
6560        let (_tmp, _guard, me) = init_test_db();
6561        let relay = MemoryRelay::new();
6562        let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
6563        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6564
6565        // A sibling leaves X: a remote tombstone strictly newer than X's add.
6566        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
6567
6568        // Now join a different community Y → republish(just_joined = Y).
6569        let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
6570        republish_community_list(&relay, Some(y.id())).await.unwrap();
6571
6572        // X must still read as LEFT in the published list; Y must be live.
6573        let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
6574        assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
6575        assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
6576    }
6577
6578    #[tokio::test]
6579    async fn sync_tears_down_a_community_a_sibling_left() {
6580        // W1 (receive side): a community still held locally that the synced 13302
6581        // shows tombstoned-and-not-live is torn down, so a leave propagates.
6582        let (_tmp, _guard, me) = init_test_db();
6583        let relay = MemoryRelay::new();
6584        let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
6585        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6586        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
6587
6588        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
6589        sync_community_list(&relay, &x.relays).await.unwrap();
6590        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
6591    }
6592
6593    #[tokio::test]
6594    async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
6595        // The re-join case must NOT be torn down: a fresh join re-adds live (beating
6596        // the tombstone), so a later sync keeps it.
6597        let (_tmp, _guard, me) = init_test_db();
6598        let relay = MemoryRelay::new();
6599        let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
6600        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6601        // A stale tombstone from a prior leave (OLDER than the current hold's re-add).
6602        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
6603        // Re-record the membership (a re-join) → live entry at now >> 1.
6604        republish_community_list(&relay, Some(x.id())).await.unwrap();
6605        sync_community_list(&relay, &x.relays).await.unwrap();
6606        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
6607    }
6608
6609    #[tokio::test]
6610    async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
6611        // W2: a transient fetch failure during republish must not drive the
6612        // replaceable-event write (which would drop other entries / regress seeds).
6613        let (_tmp, _guard, _me) = init_test_db();
6614        let good = MemoryRelay::new();
6615        let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
6616        assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
6617
6618        // A transport whose fetch always errors: republish must bail, publishing nothing.
6619        struct FetchErrors;
6620        #[async_trait::async_trait]
6621        impl Transport for FetchErrors {
6622            async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6623                panic!("republish must NOT publish when the remote fetch failed");
6624            }
6625            async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6626                Ok(())
6627            }
6628            async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
6629                Err("relay unreachable".to_string())
6630            }
6631        }
6632        // Returns Ok (best-effort) but must not have published (the panic guards it).
6633        republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
6634    }
6635
6636    #[tokio::test]
6637    async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
6638        // B1 regression: refound_community's recipient set = memberlist. A member
6639        // the owner GRANTED a role to but who never left a (surviving) Guestbook
6640        // Join — a lurking admin, or one whose Join aged out of the window — must
6641        // still be a rekey recipient, or the Refounding SEVERS them. The folded
6642        // roster's granted members are the consensus-complete backstop.
6643        let (_tmp, _guard, owner) = init_test_db();
6644        let relay = MemoryRelay::new();
6645        let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
6646
6647        // A lurker gets an admin grant but publishes NO Guestbook Join and no chat.
6648        let lurker = Keys::generate();
6649        let rid = "b1".repeat(32);
6650        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
6651        publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
6652
6653        // memberlist includes the lurker purely via the roster backstop.
6654        let members = memberlist(&relay, &community).await.unwrap();
6655        assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
6656
6657        // A banned grantee whose grant wasn't stripped is NOT re-admitted.
6658        let banned_grantee = Keys::generate();
6659        publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
6660        set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
6661        let members = memberlist(&relay, &community).await.unwrap();
6662        assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
6663        assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
6664
6665        // And the Refounding actually delivers the new root to the lurker.
6666        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
6667        assert_eq!(refounded.root_epoch, Epoch(1));
6668        let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
6669        let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
6670        let rotations = rekey::collect_rotations(&chunks);
6671        let lurker_x = lurker.public_key().to_bytes();
6672        let delivered = rotations.iter().any(|r| {
6673            rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
6674        });
6675        assert!(delivered, "the Refounding delivered the new root to the granted lurker");
6676    }
6677
6678    #[tokio::test]
6679    async fn the_memberlist_pages_past_a_guestbook_flood() {
6680        // The roleless-member half of B1: >500 Guestbook events must not evict an
6681        // honest member's Join from the counted set (an insider can flood throwaway
6682        // Joins to force exactly this). The pager sees them all.
6683        let (_tmp, _guard, owner) = init_test_db();
6684        let relay = MemoryRelay::new();
6685        let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
6686        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
6687
6688        // An honest member's Join (oldest), then 600 throwaway Joins on top.
6689        let honest = Keys::generate();
6690        let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
6691        let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
6692        relay.publish(&w, &community.relays).await.unwrap();
6693        for i in 0..600u64 {
6694            let throwaway = Keys::generate();
6695            let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
6696            let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
6697            relay.publish(&w, &community.relays).await.unwrap();
6698        }
6699
6700        let members = memberlist(&relay, &community).await.unwrap();
6701        assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
6702    }
6703
6704    #[tokio::test]
6705    async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
6706        // An insider floods the next-epoch rekey address (community_root-derived,
6707        // so any member can seal there) with >200 junk 3303s to push the owner's
6708        // genuine rotation out of a single fetch window. The paginated fetch must
6709        // still recover it and adopt.
6710        let (_tmp, _guard, owner) = init_test_db();
6711        let relay = MemoryRelay::new();
6712        let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
6713        let new_root = [0xD9; 32];
6714        let new_epoch = Epoch(1);
6715        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
6716
6717        // The GENUINE owner rotation lands first (oldest).
6718        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
6719
6720        // Then a member floods 260 well-formed-but-unauthorized junk chunks ON TOP
6721        // (newer), burying the genuine one past the 200 newest.
6722        let rogue = Keys::generate();
6723        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
6724        for i in 0..260u64 {
6725            let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
6726            let rumor = rekey::build_rekey_rumor(rogue.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 1, 3_000 + i).unwrap();
6727            let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
6728            relay.publish(&wrap, &community.relays).await.unwrap();
6729        }
6730
6731        let session = SessionGuard::capture();
6732        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
6733        assert_eq!(updated.root_epoch, Epoch(1));
6734        assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
6735    }
6736
6737    #[tokio::test]
6738    async fn a_swap_during_create_private_channel_aborts_without_a_write() {
6739        // create_private_channel straddles a memberlist fetch (seconds long) then
6740        // whole-row-saves. A swap in that window must abort — never mint a channel
6741        // into the swapped-in account, and never leave a half-published key crate
6742        // adopted locally.
6743        let (bed, owner, _member) = TestBed::new();
6744        bed.swap_to(&owner);
6745        let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
6746        let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
6747
6748        // The memberlist fetch inside create bumps the generation mid-flight.
6749        let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
6750        let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
6751        assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
6752        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6753        assert_eq!(after.channels.len(), before, "no channel row was written");
6754        assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
6755    }
6756
6757    #[tokio::test]
6758    async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
6759        // CORD-06 §Failure-and-races: two DISTINCT authorized rotators mint the
6760        // same channel epoch concurrently (reachable — both hold MANAGE_CHANNELS).
6761        // Every follower must converge on the SAME key (the lexicographically
6762        // lowest), so the community never permanently forks. (Retaining the losing
6763        // fork's key for its race-window messages needs a multi-key-per-epoch
6764        // archive — a deferred refinement shared with v1; convergence, the
6765        // security-critical property, is what this pins.)
6766        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6767        let (_tmp, _guard, owner) = init_test_db();
6768        let relay = MemoryRelay::new();
6769        let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
6770        let priv_id = ChannelId([0xC0; 32]);
6771        let key1 = [0xC1; 32];
6772        add_private_channel(&mut community, priv_id, key1, Epoch(1));
6773
6774        // Two admins (a, b) both hold the Admin role; I hold the channel key.
6775        let (a, b) = (Keys::generate(), Keys::generate());
6776        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6777        let role = Role::admin("ce".repeat(32));
6778        let roster = CommunityRoles {
6779            roles: vec![role.clone()],
6780            grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
6781        };
6782        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
6783
6784        // Both rotate 1 → 2, each delivering their OWN fresh key to me, off the
6785        // same prevcommit — a genuine same-epoch fork.
6786        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
6787        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6788        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
6789        let key_a = [0x0A; 32];
6790        let key_b = [0xFB; 32]; // higher — a's must win regardless of publish order
6791        for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
6792            let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
6793            for e in rekey::build_rekey_chunks_local(signer, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000).unwrap() {
6794                relay.publish(&e, &community.relays).await.unwrap();
6795            }
6796        }
6797
6798        let session = SessionGuard::capture();
6799        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
6800        let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
6801        assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
6802
6803        // A SECOND follower (fresh, holding the same epoch-1 key) converges identically.
6804        let mut peer = community.clone();
6805        if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
6806            c.key = Some(key1);
6807            c.epoch = Epoch(1);
6808        }
6809        // Re-run the same fold from the peer's identical starting point → same winner.
6810        let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
6811        assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
6812    }
6813
6814    #[tokio::test]
6815    async fn create_private_channel_refuses_a_member_without_manage_channels() {
6816        // The local mirror of the reader's gate: an unauthorized member is refused
6817        // BEFORE any publish (no floor pollution, no orphan key crate).
6818        let (bed, owner, member) = TestBed::new();
6819        bed.swap_to(&owner);
6820        let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
6821        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6822
6823        bed.swap_to(&member);
6824        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6825        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6826        let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
6827        assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
6828        let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
6829        assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
6830    }
6831
6832    // ── Audit regressions ────────────────────────────────────────────────────
6833
6834    #[tokio::test]
6835    async fn accept_rejects_a_bundle_with_a_forged_community_root() {
6836        // The eclipse: community_id commits only to (owner, salt) — both semi-public
6837        // — so a forged invite pairs the REAL triple with an attacker root, and every
6838        // plane derives from it. The join-time owner-genesis check must refuse.
6839        let (bed, owner, member) = TestBed::new();
6840        bed.swap_to(&owner);
6841        let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
6842
6843        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
6844        let mut forged = bundle_of(&community, None, None, None);
6845        forged.community_root = fake.clone();
6846        for ch in &mut forged.channels {
6847            ch.key = fake.clone();
6848        }
6849        let attacker = Keys::generate();
6850        let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
6851        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
6852
6853        bed.swap_to(&member);
6854        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6855        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6856        assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
6857        assert!(
6858            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6859            "a rejected join persists nothing"
6860        );
6861    }
6862
6863    #[tokio::test]
6864    async fn follow_control_heals_a_bundle_misclassified_public_channel() {
6865        // A bundle can set a PUBLIC channel's grant key to the attacker's, so the
6866        // joiner addresses it at a plane only the attacker reads. The owner's genuine
6867        // public:false edition must override it on follow.
6868        let (_tmp, _guard, _owner) = init_test_db();
6869        let relay = MemoryRelay::new();
6870        let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
6871        let general = community.channels[0].id;
6872        let mut poisoned = community.clone();
6873        poisoned.channels[0].private = true;
6874        poisoned.channels[0].key = Some([0x66; 32]);
6875        crate::db::community::save_community_v2(&poisoned).unwrap();
6876
6877        let session = SessionGuard::capture();
6878        let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
6879        let ch = healed.channel(&general).unwrap();
6880        assert!(!ch.private, "the owner's public declaration overrides the bundle");
6881        assert_eq!(ch.key, None, "a healed public channel derives from the root");
6882    }
6883
6884    #[tokio::test]
6885    async fn a_deleted_channel_does_not_resurrect_on_reload() {
6886        // save_community_v2 must prune orphan channel rows, or a control-follow delete
6887        // reappears (with a stale key) on the next reload.
6888        let (_tmp, _guard, owner) = init_test_db();
6889        let relay = MemoryRelay::new();
6890        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
6891        let extra = ChannelId([0x77; 32]);
6892        let session = SessionGuard::capture();
6893        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
6894        let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
6895        assert!(with_extra.channel(&extra).is_some());
6896        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
6897        let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
6898        assert!(after.channel(&extra).is_none());
6899
6900        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6901        assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
6902        assert_eq!(reloaded.channels.len(), 1);
6903    }
6904
6905    #[tokio::test]
6906    async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
6907        // channel_id is the sole DB primary key, so a bundle/replay reusing another
6908        // community's channel_id must NOT overwrite that row. It's skipped (not an
6909        // error — erroring would wedge all of this community's control persistence).
6910        let (_tmp, _guard, _owner) = init_test_db();
6911        let relay = MemoryRelay::new();
6912        let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
6913        let a_channel = a.channels[0].id;
6914        let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
6915        let b_channel = b.channels[0].id;
6916        // B's set includes a phantom whose id collides with A's channel.
6917        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() });
6918
6919        crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
6920        // A's channel row is untouched.
6921        let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
6922        assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
6923        assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
6924        // B keeps its own channel but never acquired a row for the foreign id.
6925        let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
6926        assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
6927        assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
6928    }
6929
6930    /// A single relay that CAPS every query below the page size (modelling a real
6931    /// relay's maxFilterLimit) and honors `until` — so the join-verify walk MUST
6932    /// paginate to reach an old genesis. MemoryRelay can't model this (it unions then
6933    /// truncates the whole set), which is why a MemoryRelay flood test gives false
6934    /// confidence about the production `LiveTransport` behaviour.
6935    struct CappedRelay {
6936        events: Vec<Event>,
6937        cap: usize,
6938    }
6939    #[async_trait::async_trait]
6940    impl Transport for CappedRelay {
6941        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6942            Ok(())
6943        }
6944        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6945            Ok(())
6946        }
6947        async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
6948            let mut m: Vec<Event> = self
6949                .events
6950                .iter()
6951                .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
6952                .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
6953                .cloned()
6954                .collect();
6955            m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); // newest first
6956            m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
6957            Ok(m)
6958        }
6959    }
6960
6961    #[tokio::test]
6962    async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
6963        // The join-verify DoS mitigation, tested against a relay that caps below PAGE
6964        // (production behaviour MemoryRelay hides): a rogue root-holder buries the
6965        // genesis under junk, and the `until`-walk must page past it. Uses fixed OLD
6966        // timestamps so `until = now` includes everything and the walk is deterministic.
6967        let (_tmp, _guard, owner) = init_test_db();
6968        let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
6969        let g = control::genesis(&owner, meta, 1_000).unwrap();
6970        let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
6971
6972        let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
6973        let rogue = Keys::generate();
6974        let mut events: Vec<Event> = g.wraps.to_vec();
6975        for i in 0..250u64 {
6976            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
6977            let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
6978            events.push(wrap);
6979        }
6980        // Cap 100/query forces the walk across ~3 pages down to the genesis at ts 1000.
6981        let relay = CappedRelay { events, cap: 100 };
6982        let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
6983        assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
6984    }
6985
6986    #[tokio::test]
6987    async fn accept_parked_invite_joins_from_the_stored_bundle() {
6988        // The 3313 receive path: an invite is parked as its bundle JSON, then accepted
6989        // from the stored bundle (re-verifying the owner root over the network).
6990        let (bed, owner, member) = TestBed::new();
6991        bed.swap_to(&owner);
6992        let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
6993        let general = community.channels[0].id;
6994        send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
6995        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
6996        let bundle_json = serde_json::to_string(&bundle).unwrap();
6997        let inviter_hex = owner.keys.public_key().to_hex();
6998
6999        bed.swap_to(&member);
7000        let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
7001        assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
7002        assert!(joined.identity.verify());
7003        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
7004        // The join seeded the verified fold as the member's initial floor, so their
7005        // first follow can't roll below the state the join just showed.
7006        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
7007        assert!(
7008            crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
7009            "the joiner's control floor is seeded from the join-time fold"
7010        );
7011
7012        // The Guestbook memberlist now folds both participants.
7013        bed.swap_to(&owner);
7014        let members = memberlist(&bed.relay, &community).await.unwrap();
7015        assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
7016    }
7017
7018    #[tokio::test]
7019    async fn accept_parked_invite_rejects_a_forged_root() {
7020        // A forged-root parked bundle (real identity triple, attacker-chosen root) fails
7021        // accept — the shared accept path re-verifies the owner root, so a parked invite
7022        // gets the same eclipse protection as a live one.
7023        let (_tmp, _guard, _owner) = init_test_db();
7024        let relay = MemoryRelay::new();
7025        let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
7026        let mut forged = bundle_of(&community, None, None, None);
7027        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
7028        forged.community_root = fake.clone();
7029        for ch in &mut forged.channels {
7030            ch.key = fake.clone();
7031        }
7032        let bundle_json = serde_json::to_string(&forged).unwrap();
7033
7034        let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
7035        assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
7036    }
7037
7038    #[test]
7039    fn v2_and_v1_bundles_are_distinguishable_by_parse() {
7040        // The protocol discriminator the facade list/accept relies on: a v2 bundle
7041        // (self-certifying: owner + owner_salt + community_root) parses; a v1-shaped
7042        // one does not, so a parked invite routes to the right accept path.
7043        let owner = Keys::generate();
7044        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
7045        let hex = crate::simd::hex::bytes_to_hex_32;
7046        let v2 = invite::CommunityInvite {
7047            community_id: hex(&identity.community_id.0),
7048            owner: hex(&identity.owner_xonly),
7049            owner_salt: hex(&identity.owner_salt),
7050            community_root: hex(&[0x11; 32]),
7051            root_epoch: 0,
7052            channels: vec![],
7053            relays: vec!["wss://r".into()],
7054            name: "V2".into(),
7055            icon: None,
7056            expires_at: None,
7057            creator_npub: None,
7058            label: None,
7059            extra: Default::default(),
7060        };
7061        let v2_json = serde_json::to_string(&v2).unwrap();
7062        assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
7063        let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
7064        assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
7065    }
7066
7067    #[tokio::test]
7068    async fn verify_rejects_a_cross_community_owner_edition_replay() {
7069        // The eclipse-via-replay: an owner-signed edition from community X (eid == X.id)
7070        // rewrapped onto a FORGED community T's fake control plane must NOT authenticate
7071        // T. T's genesis has eid == T.id, so X's edition — a genuine owner signature but
7072        // a different eid — is not a valid proof of T's root. This is why "any owner
7073        // edition" is unsound and the eid==community_id genesis pin is required.
7074        let (_tmp, _guard, owner) = init_test_db();
7075
7076        // Community X (real), owned by `owner`.
7077        let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
7078        let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
7079        let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
7080
7081        // Forged community T: the real owner triple but an ATTACKER-chosen root.
7082        let t_identity = control::CommunityIdentity::mint(&owner.public_key());
7083        let fake_root = [0xEE; 32];
7084        let t = CommunityV2 {
7085            identity: t_identity,
7086            community_root: fake_root,
7087            root_epoch: Epoch(0),
7088            name: "T".into(),
7089            description: None,
7090            icon: None,
7091            banner: None,
7092            meta_custom: None,
7093            meta_extra: Default::default(),
7094            relays: vec!["wss://r".into()],
7095            channels: vec![],
7096            dissolved: false,
7097            created_at_ms: 0,
7098        };
7099        // Rewrap X's owner-signed genesis onto T's fake control plane (the attacker
7100        // controls the fake root, so they can derive its control group key).
7101        let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
7102        let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
7103        let relay = MemoryRelay::new();
7104        relay.publish(&replayed, &t.relays).await.unwrap();
7105
7106        let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
7107        assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
7108    }
7109
7110    /// LIVE smoke test (network) — ignored by default. Creates a v2 community on a
7111    /// REAL relay via `LiveTransport`, sends a message, fetches it back, and mints
7112    /// a public link. A fresh throwaway identity in an isolated temp data dir, so
7113    /// it never touches real accounts. Run explicitly:
7114    /// ```sh
7115    /// cargo test -p vector-core -- --ignored --nocapture live_smoke
7116    /// ```
7117    #[tokio::test]
7118    #[ignore = "hits a real relay over the network"]
7119    async fn live_smoke_create_send_fetch_on_a_real_relay() {
7120        use crate::community::transport::LiveTransport;
7121        use nostr_sdk::prelude::ToBech32;
7122
7123        let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
7124        let relays = vec![relay.clone()];
7125
7126        // Isolated account + data dir (a fresh throwaway key — never a real account).
7127        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
7128        crate::db::close_database();
7129        crate::db::clear_id_caches();
7130        let tmp = tempfile::tempdir().unwrap();
7131        // Bring your own key (VECTOR_SMOKE_NSEC) to create a community you can log
7132        // into elsewhere; otherwise a fresh throwaway.
7133        let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
7134            Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
7135            Err(_) => Keys::generate(),
7136        };
7137        let npub = keys.public_key().to_bech32().unwrap();
7138        // Off by default (never leak secrets from a committed test); set
7139        // VECTOR_SMOKE_PRINT_NSEC=1 to print the owner nsec for cross-client login.
7140        if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
7141            println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
7142        }
7143        std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
7144        crate::db::set_app_data_dir(tmp.path().to_path_buf());
7145        crate::db::set_current_account(npub.clone()).unwrap();
7146        crate::db::init_database(&npub).unwrap();
7147        crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
7148        crate::state::set_my_public_key(keys.public_key());
7149        println!("[smoke] throwaway identity {npub}");
7150
7151        // A live client (LiveTransport rides the global NOSTR_CLIENT + warms relays).
7152        let client = nostr_sdk::ClientBuilder::new().signer(keys.clone()).build();
7153        client.pool().add_relay(relay.as_str(), nostr_sdk::RelayOptions::default()).await.ok();
7154        client.connect().await;
7155        crate::state::set_nostr_client(client);
7156        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
7157
7158        // Create → send → fetch-back → verify.
7159        let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
7160        let general = community.channels[0].id;
7161        println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
7162
7163        let text = "hello from a Vector Concord v2 live smoke test";
7164        let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
7165        println!("[smoke] sent message {sent_id}");
7166
7167        // Give the relay a moment to store + be ready to serve it.
7168        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
7169
7170        let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
7171        let texts: Vec<String> = page
7172            .iter()
7173            .filter_map(|f| match &f.event {
7174                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
7175                _ => None,
7176            })
7177            .collect();
7178        println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
7179        assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
7180
7181        // Mint a shareable v2 link (the thing a bot hands out).
7182        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
7183        println!("[smoke] invite link: {}", link.url);
7184        println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
7185    }
7186
7187    #[tokio::test]
7188    async fn chat_ops_react_edit_delete_round_trip() {
7189        let (bed, owner, _member) = TestBed::new();
7190        bed.swap_to(&owner);
7191        let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
7192        let general = community.channels[0].id;
7193        let me_hex = owner.keys.public_key().to_hex();
7194
7195        let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
7196        send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
7197            .await
7198            .unwrap();
7199        send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
7200        send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
7201
7202        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7203        let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
7204        let mut saw = (false, false, false);
7205        for f in &page {
7206            match &f.event {
7207                ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
7208                    assert_eq!(emoji, ":fire:");
7209                    assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
7210                    saw.0 = true;
7211                }
7212                ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
7213                    assert_eq!(new_content, "edited");
7214                    saw.1 = true;
7215                }
7216                ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
7217                _ => {}
7218            }
7219        }
7220        assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
7221    }
7222
7223    #[tokio::test]
7224    async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
7225        let (bed, owner, _member) = TestBed::new();
7226        bed.swap_to(&owner);
7227        let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
7228        let general = community.channels[0].id;
7229        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
7230
7231        // A live subscriber sees the 21059 wrap and it opens as Typing…
7232        let mut sub = bed.relay.subscribe(Query {
7233            kinds: vec![stream::KIND_WRAP_EPHEMERAL],
7234            authors: vec![group.pk_hex()],
7235            ..Default::default()
7236        });
7237        send_typing(&bed.relay, &community, &general).await.unwrap();
7238        let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
7239        let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
7240            Ok(ChatEvent::Typing { opened }) => opened,
7241            other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
7242        };
7243
7244        // …while nothing durable is stored (relays never keep the ephemeral tier),
7245        // so channel history stays free of typing noise…
7246        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7247        assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
7248
7249        // …and no scrub key is retained (there is no durable wrap to ever delete).
7250        assert!(
7251            crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
7252            "ephemeral sends must not retain scrub keys"
7253        );
7254    }
7255
7256    #[tokio::test]
7257    async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
7258        let (bed, owner, _member) = TestBed::new();
7259        bed.swap_to(&owner);
7260        let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
7261        let general = community.channels[0].id;
7262        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
7263
7264        let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
7265
7266        // Retained: the row maps the rumor id to the exact published wrap, holds the
7267        // key that SIGNED that wrap (same-author NIP-09), and the relay set.
7268        let (keys, outer_hex, relays) =
7269            crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
7270        assert_eq!(relays, community.relays);
7271        let wrap_query = Query {
7272            kinds: vec![stream::KIND_WRAP],
7273            authors: vec![group.pk_hex()],
7274            ..Default::default()
7275        };
7276        let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
7277        let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
7278        assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
7279
7280        // Reactions ride the same retention (revoke_reaction's relay-nuke layer).
7281        let me_hex = owner.keys.public_key().to_hex();
7282        let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
7283            .await
7284            .unwrap();
7285        assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
7286
7287        // The shared v1 delete path (Layer 1 of delete_community_message / revoke_reaction)
7288        // scrubs the wrap off the relay via the retained key, then consumes the row.
7289        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
7290        assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
7291        let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
7292        assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
7293    }
7294
7295    #[tokio::test]
7296    async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
7297        let (bed, owner, _member) = TestBed::new();
7298        bed.swap_to(&owner);
7299        let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
7300        let general = community.channels[0].id;
7301        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
7302
7303        // Simulate a pre-retention / other-device send: our message on the relay,
7304        // but no local mapping row.
7305        let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
7306        crate::db::community::delete_message_key(&id).unwrap();
7307        assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
7308
7309        // A stranger member's message rides the same channel.
7310        let mkeys = Keys::generate();
7311        let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
7312        let foreign_id = rumor.id.unwrap().to_hex();
7313        let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
7314        bed.relay.publish(&fw, &community.relays).await.unwrap();
7315
7316        // One history open re-derives the mapping for the OWN message…
7317        fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7318        let (keys, _outer, relays) =
7319            crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
7320        assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
7321        assert_eq!(relays, community.relays);
7322
7323        // …and never manufactures one for a foreign author.
7324        assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
7325
7326        // The healed row is a working full delete: the shared path scrubs the wrap.
7327        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
7328        let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7329        assert!(
7330            !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
7331            "healed message scrubbed from the relay"
7332        );
7333    }
7334
7335    #[tokio::test]
7336    async fn send_chat_message_threads_the_reply_and_extra_tags() {
7337        let (bed, owner, _member) = TestBed::new();
7338        bed.swap_to(&owner);
7339        let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
7340        let general = community.channels[0].id;
7341        let me_hex = owner.keys.public_key().to_hex();
7342
7343        let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
7344        let imeta = nostr_sdk::prelude::Tag::custom(
7345            nostr_sdk::prelude::TagKind::custom("imeta"),
7346            ["url https://e/blob".to_string(), "m image/png".to_string()],
7347        );
7348        let child_id = send_chat_message(
7349            &bed.relay, &community, &general, "child",
7350            Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
7351        )
7352        .await
7353        .unwrap();
7354
7355        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7356        let child = page
7357            .iter()
7358            .find_map(|f| match &f.event {
7359                ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
7360                _ => None,
7361            })
7362            .expect("the reply message round-trips");
7363        let reply = child.1.as_ref().expect("the reply reference is carried");
7364        assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
7365        assert_eq!(reply.author, Some(owner.keys.public_key()));
7366        assert!(
7367            child.0.rumor.tags.iter().any(|t| t.kind() == nostr_sdk::prelude::TagKind::custom("imeta")),
7368            "the imeta attachment tag rides the rumor verbatim"
7369        );
7370    }
7371
7372    #[tokio::test]
7373    async fn a_kick_needs_kick_authority_and_removes_the_target() {
7374        let (bed, owner, member) = TestBed::new();
7375        bed.swap_to(&owner);
7376        let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
7377
7378        // The target announces a Join (as an accepted invite would).
7379        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
7380        let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
7381        let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
7382        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
7383        let before = memberlist(&bed.relay, &community).await.unwrap();
7384        assert!(before.contains(&member.keys.public_key()), "the join lands first");
7385
7386        // An unprivileged member's kick of the owner is refused locally…
7387        bed.swap_to(&member);
7388        let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
7389        assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
7390
7391        // …and the owner (supreme, no grant needed) kicks the member out.
7392        bed.swap_to(&owner);
7393        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7394        let after = memberlist(&bed.relay, &community).await.unwrap();
7395        assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
7396        assert!(after.contains(&owner.keys.public_key()), "the owner remains");
7397    }
7398
7399    #[tokio::test]
7400    async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
7401        let (bed, owner, member) = TestBed::new();
7402        bed.swap_to(&owner);
7403        let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
7404        let member_pk = member.keys.public_key();
7405        let member_hex = member_pk.to_hex();
7406        let owner_hex = owner.keys.public_key().to_hex();
7407
7408        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
7409        let view = fetch_authority(&bed.relay, &community).await;
7410        assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
7411        assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
7412
7413        // A second grant (any device) converges on the SAME role entity — and a
7414        // repeat is a no-op, not a version bump.
7415        let second = Keys::generate().public_key();
7416        grant_admin(&bed.relay, &community, &second).await.unwrap();
7417        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
7418        let view = fetch_authority(&bed.relay, &community).await;
7419        assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
7420        assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
7421        let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
7422        assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
7423
7424        // Revoke strips ONLY the admin role and de-authorizes.
7425        revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
7426        let view = fetch_authority(&bed.relay, &community).await;
7427        assert!(!view.roles.is_admin(&member_hex), "revoked");
7428        assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
7429        assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
7430    }
7431
7432    #[tokio::test]
7433    async fn follow_control_persists_the_roster_for_sync_local_reads() {
7434        let (bed, owner, member) = TestBed::new();
7435        bed.swap_to(&owner);
7436        let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
7437        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7438        let member_hex = member.keys.public_key().to_hex();
7439        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7440
7441        // The passive follow folds + persists; the read is then LOCAL (v1 parity).
7442        let session = crate::state::SessionGuard::capture();
7443        follow_control(&bed.relay, &community, &session).await.unwrap();
7444        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7445        assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
7446
7447        // A withholding relay serves nothing — an empty fold raises no gap flag, and
7448        // the stored roster must be RETAINED, never wiped.
7449        let withholding = MemoryRelay::new();
7450        let _ = follow_control(&withholding, &community, &session).await;
7451        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7452        assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
7453
7454        // A real revocation (a NEWER grant edition) does replace it.
7455        revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7456        follow_control(&bed.relay, &community, &session).await.unwrap();
7457        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7458        assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
7459    }
7460
7461    #[tokio::test]
7462    async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
7463        let (bed, owner, member) = TestBed::new();
7464        bed.swap_to(&owner);
7465        let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
7466
7467        bed.swap_to(&member);
7468        let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
7469        assert!(err.contains("owner"), "refused before any publish: {err}");
7470
7471        // The deterministic admin-role entity stays unsquatted — the owner's later
7472        // legitimate mint is version 1 and folds cleanly.
7473        bed.swap_to(&owner);
7474        let view = fetch_authority(&bed.relay, &community).await;
7475        assert!(view.roles.roles.is_empty(), "no role edition landed");
7476        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7477        let view = fetch_authority(&bed.relay, &community).await;
7478        assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
7479    }
7480
7481    #[tokio::test]
7482    async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
7483        let (bed, owner, member) = TestBed::new();
7484        bed.swap_to(&owner);
7485        let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
7486        let member_pk = member.keys.public_key();
7487
7488        // The member already holds a Mod role, granted through the real send path
7489        // (so this device's floors track both entities).
7490        let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
7491        set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
7492        grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
7493
7494        // A relay that withholds the control plane must refuse the merge — a blind
7495        // push would erase the Mod role at a higher version.
7496        let withholding = MemoryRelay::new();
7497        let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
7498        assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
7499
7500        // Against the full relay the merge preserves the Mod role.
7501        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
7502        let view = fetch_authority(&bed.relay, &community).await;
7503        let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
7504        assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
7505        assert!(grant.role_ids.contains(&mod_rid));
7506    }
7507
7508    #[tokio::test]
7509    async fn fetch_authority_reflects_a_granted_admin() {
7510        let (bed, owner, member) = TestBed::new();
7511        bed.swap_to(&owner);
7512        let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
7513        let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
7514        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7515        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
7516
7517        let view = fetch_authority(&bed.relay, &community).await;
7518        let member_hex = member.keys.public_key().to_hex();
7519        assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
7520        assert!(
7521            view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
7522            "an ADMIN_ALL grant carries KICK"
7523        );
7524        assert!(view.banned.is_empty());
7525    }
7526}