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    for wrap in &genesis.wraps {
92        transport.publish(wrap, &community.relays).await?;
93    }
94
95    // Announce the owner's Guestbook Join so they appear in the memberlist.
96    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
97    let join_rumor = guestbook::build_join_rumor(owner.public_key(), None, at_ms);
98    if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor(&join_rumor, &gb_group, &owner, Timestamp::from_secs(at_ms / 1000)) {
99        let _ = transport.publish(&join_wrap, &community.relays).await;
100    }
101
102    // Sync the new membership across devices (CORD-02 §8) — best-effort.
103    let _ = republish_community_list(transport, Some(community.id())).await;
104    Ok(community)
105}
106
107/// Send a text message to a channel. Derives the channel's Chat-Plane group key
108/// (community_root for a Public channel, the channel key for a Private one),
109/// seals it encrypted, and publishes. Returns the message's rumor id (hex).
110pub async fn send_message<T: Transport + ?Sized>(
111    transport: &T,
112    community: &CommunityV2,
113    channel_id: &ChannelId,
114    content: &str,
115) -> Result<String, String> {
116    send_chat_message(transport, community, channel_id, content, None, &[], vec![]).await
117}
118
119/// Full chat send: threaded reply (NIP-C7 `q`, the parent's `(rumor_id, author)`
120/// hex pair), NIP-30 custom-emoji pairs, and verbatim extra tags (NIP-92 `imeta`
121/// attachments). Returns the message's rumor id (hex).
122pub async fn send_chat_message<T: Transport + ?Sized>(
123    transport: &T,
124    community: &CommunityV2,
125    channel_id: &ChannelId,
126    content: &str,
127    reply_to: Option<(&str, &str)>,
128    emoji: &[(&str, &str)],
129    extra_tags: Vec<nostr_sdk::prelude::Tag>,
130) -> Result<String, String> {
131    let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
132    let at_ms = now_ms();
133    let rumor = chat::build_message_rumor(author.public_key(), channel_id, epoch, content, reply_to, emoji, extra_tags, at_ms);
134    publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
135}
136
137/// React to a channel message (kind 7, NIP-25 shape). `target_id_hex` /
138/// `target_author_hex` name the reacted-to message; `target_kind` is its rumor
139/// kind (`kind::MESSAGE`, or `kind::COMMENT` for a threaded reply); `emoji`
140/// carries the NIP-30 pair when `emoji_content` is a custom `:shortcode:`.
141#[allow(clippy::too_many_arguments)]
142pub async fn send_reaction<T: Transport + ?Sized>(
143    transport: &T,
144    community: &CommunityV2,
145    channel_id: &ChannelId,
146    target_id_hex: &str,
147    target_author_hex: &str,
148    target_kind: u16,
149    emoji_content: &str,
150    emoji: Option<(&str, &str)>,
151) -> Result<String, String> {
152    let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
153    let at_ms = now_ms();
154    let rumor =
155        chat::build_reaction_rumor(author.public_key(), channel_id, epoch, target_id_hex, target_author_hex, target_kind, emoji_content, emoji, at_ms);
156    publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
157}
158
159/// Edit one of your own messages (kind 3302): peers re-render `target_id_hex`
160/// with the replacement text. Author-enforced on the read side — only the
161/// original author's edit folds.
162pub async fn send_edit<T: Transport + ?Sized>(
163    transport: &T,
164    community: &CommunityV2,
165    channel_id: &ChannelId,
166    target_id_hex: &str,
167    new_content: &str,
168) -> Result<String, String> {
169    let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
170    let at_ms = now_ms();
171    let rumor = chat::build_edit_rumor(author.public_key(), channel_id, epoch, target_id_hex, new_content, at_ms);
172    publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
173}
174
175/// Cooperative in-plane delete (kind 5, NIP-09 semantics): peers stop rendering
176/// `target_id_hex`. The wrap ciphertext on relays needs a separate NIP-09 scrub
177/// by its ephemeral key — not retained in this cut.
178pub async fn send_delete<T: Transport + ?Sized>(
179    transport: &T,
180    community: &CommunityV2,
181    channel_id: &ChannelId,
182    target_id_hex: &str,
183    target_kind: u16,
184) -> Result<String, String> {
185    let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
186    let at_ms = now_ms();
187    let rumor = chat::build_delete_rumor(author.public_key(), channel_id, epoch, target_id_hex, target_kind, at_ms);
188    publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
189}
190
191/// Ephemeral typing indicator (kind 23311 in a 21059 wrap — relays never store it).
192pub async fn send_typing<T: Transport + ?Sized>(
193    transport: &T,
194    community: &CommunityV2,
195    channel_id: &ChannelId,
196) -> Result<(), String> {
197    let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
198    let at_ms = now_ms();
199    let rumor = chat::build_typing_rumor(author.public_key(), channel_id, epoch, at_ms);
200    publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
201}
202
203/// Everything a chat-plane send needs: local keys, the channel's group key +
204/// epoch, and the session snapshot taken BEFORE any await. Refuses a dissolved
205/// community (every honest member sealed it read-only) and a keyless Private
206/// channel — deriving from the root would post to the public plane; its key
207/// arrives over the rekey plane.
208fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(Keys, GroupKey, Epoch, SessionGuard), String> {
209    let session = SessionGuard::capture();
210    let author = local_keys()?;
211    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
212    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
213        return Err("this community has been dissolved".to_string());
214    }
215    // A self-ban: every honest peer drops our events (CORD-04 §4) and the send
216    // echo would silently no-op, so fail loudly instead of a message that seems
217    // to send but shows up nowhere.
218    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author.public_key().to_hex()) {
219        return Err("you are banned from this community".to_string());
220    }
221    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
222    if ch.private && ch.key.is_none() {
223        return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
224    }
225    let (secret, epoch) = community.channel_secret(ch);
226    Ok((author, channel_group_key(&secret, channel_id, epoch), epoch, session))
227}
228
229/// Seal one chat rumor, re-check the session, publish, and echo the send into the
230/// shared store. Returns the rumor id (hex).
231#[allow(clippy::too_many_arguments)]
232async fn publish_chat<T: Transport + ?Sized>(
233    transport: &T,
234    community: &CommunityV2,
235    session: &SessionGuard,
236    group: &GroupKey,
237    author: &Keys,
238    channel_id: &ChannelId,
239    epoch: Epoch,
240    rumor: nostr_sdk::prelude::UnsignedEvent,
241    at_ms: u64,
242    ephemeral: bool,
243) -> Result<String, String> {
244    let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
245    let (wrap, _ephemeral_keys) = chat::seal_chat_rumor(&rumor, group, author, Timestamp::from_secs(at_ms / 1000), ephemeral)
246        .map_err(|e| e.to_string())?;
247    if !session.is_valid() {
248        return Err("account changed before send".to_string());
249    }
250    transport.publish(&wrap, &community.relays).await?;
251    // Local echo (v1 parity): open our OWN wrap through the exact inbound path so
252    // send-then-read works with no listen loop, and the relay's re-delivery dedups
253    // against this row instead of re-firing callbacks. Best-effort — the publish
254    // already succeeded. Ephemeral kinds (typing) apply to nothing and skip out.
255    if !ephemeral {
256        if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
257            let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
258            let outcome = {
259                let mut st = crate::state::STATE.lock().await;
260                if !session.is_valid() {
261                    return Ok(rumor_id); // swapped on the lock await — never echo into another account.
262                }
263                super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author.public_key())
264            };
265            if let Some(outcome) = outcome {
266                if !session.is_valid() {
267                    return Ok(rumor_id);
268                }
269                super::inbound::persist_chat(&channel_hex, &outcome).await;
270            }
271        }
272    }
273    Ok(rumor_id)
274}
275
276/// A chat event opened from a channel fetch, tagged with the epoch its key
277/// decrypted under.
278pub struct FetchedEvent {
279    pub event: ChatEvent,
280    pub epoch: Epoch,
281}
282
283/// Fetch a channel's newest messages — one page of [`fetch_channel_history`].
284/// `limit` is one relay-side bound across the whole epoch-author OR-set, not
285/// per epoch; deeper history pages backwards via the walk.
286pub async fn fetch_channel<T: Transport + ?Sized>(
287    transport: &T,
288    community: &CommunityV2,
289    channel_id: &ChannelId,
290    limit: usize,
291) -> Result<Vec<FetchedEvent>, String> {
292    fetch_channel_history(transport, community, channel_id, limit, 1, |_| true).await
293}
294
295/// Walk a channel's history newest-first (CORD-03 §3 "clients load a Channel
296/// newest-first and paginate backwards"), querying every held epoch's Chat-Plane
297/// address one `page`-sized query at a time until `max_pages`, a drained relay,
298/// or `keep_paging` returns false for a page (the caller's "I already hold
299/// these" early stop — consulted only on pages that opened something, so junk
300/// at the address can't fake exhaustion). Pages step by INCLUSIVE `until` with
301/// wrap-id dedup, so a page boundary landing mid-second can't skip siblings; a
302/// full page of only-already-seen wraps is a same-second WALL (relay filters
303/// are second-granular) and steps past it accepting that unseen same-second
304/// siblings beyond the relay cap are unreachable — logged, and a protocol-level
305/// limitation (the `ms` tag can't be filtered server-side).
306///
307/// Returns everything opened, deduped by rumor id, oldest→newest.
308pub async fn fetch_channel_history<T: Transport + ?Sized>(
309    transport: &T,
310    community: &CommunityV2,
311    channel_id: &ChannelId,
312    page: usize,
313    max_pages: usize,
314    mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
315) -> Result<Vec<FetchedEvent>, String> {
316    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
317    // A Public channel reads across EVERY held base-root epoch, and a Private one
318    // across its OWN held epochs (CORD-03 §3), so history spanning a rotation stays
319    // continuous either way. A keyless Private channel is unreadable — never derived
320    // from the root (that would address the public plane).
321    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
322    let coords: Vec<([u8; 32], Epoch)> = if ch.private {
323        let Some(current) = ch.key else {
324            return Ok(Vec::new());
325        };
326        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
327        let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
328        if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
329            held.push((ch.epoch, current));
330        }
331        // Only real grants are archived, but keep the invariant local: a private
332        // plane is never read with the root value.
333        held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
334    } else {
335        let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
336        if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
337            roots.push((community.root_epoch, community.community_root));
338        }
339        roots.into_iter().map(|(ep, root)| (root, ep)).collect()
340    };
341    if coords.is_empty() {
342        return Ok(Vec::new());
343    }
344
345    // Address every held epoch by its Chat-Plane pubkey.
346    let authors: Vec<String> = coords
347        .iter()
348        .map(|(secret, epoch)| channel_group_key(secret, channel_id, *epoch).pk_hex())
349        .collect();
350
351    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
352    let mut seen_rumors = std::collections::HashSet::new();
353    let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
354    let mut until: Option<u64> = None;
355    let mut oldest: Option<u64> = None;
356    for _ in 0..max_pages {
357        let query = Query {
358            kinds: vec![stream::KIND_WRAP],
359            authors: authors.clone(),
360            until,
361            limit: Some(page),
362            ..Default::default()
363        };
364        let wraps = transport.fetch(&query, &community.relays).await?;
365        if wraps.is_empty() {
366            break;
367        }
368        let mut fresh = 0usize;
369        let mut page_events: Vec<FetchedEvent> = Vec::new();
370        for wrap in &wraps {
371            if !seen_wraps.insert(wrap.id) {
372                continue;
373            }
374            fresh += 1;
375            let at = wrap.created_at.as_secs();
376            if oldest.is_none_or(|o| at < o) {
377                oldest = Some(at);
378            }
379            // Select the epoch whose group key authored this wrap (no trial decrypt).
380            for (secret, epoch) in &coords {
381                let group = channel_group_key(secret, channel_id, *epoch);
382                if wrap.pubkey != group.pk() {
383                    continue;
384                }
385                if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
386                    let id = event.opened().rumor_id;
387                    if seen_rumors.insert(id) {
388                        page_events.push(FetchedEvent { event, epoch: *epoch });
389                    }
390                }
391                break;
392            }
393        }
394        if fresh == 0 {
395            if wraps.len() < page {
396                break; // drained — the relay has nothing older.
397            }
398            // A full page of already-seen wraps: a same-second WALL. Step past it;
399            // same-second siblings beyond the relay's cap are unreachable by a
400            // second-granular filter.
401            let Some(o) = oldest else { break };
402            if o == 0 {
403                break;
404            }
405            crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
406            until = Some(o - 1);
407            continue;
408        }
409        let stop = !page_events.is_empty() && !keep_paging(&page_events);
410        out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
411        if stop {
412            break; // the caller holds everything from here back.
413        }
414        until = oldest; // inclusive — wrap-id dedup absorbs the boundary overlap.
415    }
416    out.sort_by_key(|(ms, _)| *ms);
417    Ok(out.into_iter().map(|(_, e)| e).collect())
418}
419
420// ── Invites (CORD-05) ────────────────────────────────────────────────────────
421
422/// Build the §1 invite bundle for this community. Every channel is granted: a
423/// Public channel carries the `community_root` as its "key" (the joiner derives
424/// the real secret from the root), a Private one its own key. The bundle
425/// self-certifies the owner, so the inviter's identity is irrelevant to trust.
426pub fn bundle_of(
427    community: &CommunityV2,
428    creator: Option<PublicKey>,
429    expires_at_ms: Option<u64>,
430    label: Option<String>,
431) -> CommunityInvite {
432    let hex = crate::simd::hex::bytes_to_hex_32;
433    let channels = community
434        .channels
435        .iter()
436        // A KEYLESS private channel can't be granted (we hold no key) — carrying the
437        // root placeholder would make the joiner classify it PUBLIC and address a
438        // private channel at the public plane. It joins their view via control-follow
439        // (keyless) and keys up at the channel's next rotation.
440        .filter(|c| !(c.private && c.key.is_none()))
441        .map(|c| invite::ChannelGrant {
442            id: hex(&c.id.0),
443            key: hex(&c.key.unwrap_or(community.community_root)),
444            epoch: c.epoch.0,
445            name: c.name.clone(),
446        })
447        .collect();
448    CommunityInvite {
449        community_id: hex(&community.identity.community_id.0),
450        owner: hex(&community.identity.owner_xonly),
451        owner_salt: hex(&community.identity.owner_salt),
452        community_root: hex(&community.community_root),
453        root_epoch: community.root_epoch.0,
454        channels,
455        relays: community.relays.clone(),
456        name: community.name.clone(),
457        icon: None,
458        expires_at: expires_at_ms,
459        creator_npub: creator.map(|p| p.to_hex()),
460        label,
461        extra: Default::default(),
462    }
463}
464
465/// Gift-wrap a Direct Invite (kind 3313) of this community straight to `recipient`
466/// and publish it to the community relays. `expires_at_ms` (unix ms) optionally
467/// bounds its shelf life; `label` is echoed in the joiner's Guestbook Join. The
468/// bundle hands over the keys; the recipient consents by accepting (nothing joins
469/// on receipt). Returns the wrap.
470pub async fn send_direct_invite<T: Transport + ?Sized>(
471    transport: &T,
472    community: &CommunityV2,
473    recipient: &PublicKey,
474    expires_at_ms: Option<u64>,
475    label: Option<String>,
476) -> Result<Event, String> {
477    let session = SessionGuard::capture();
478    let inviter = local_keys()?;
479    let bundle = bundle_of(community, Some(inviter.public_key()), expires_at_ms, label);
480    let wrap = invite::build_direct_invite(&inviter, recipient, &bundle).map_err(|e| e.to_string())?;
481    if !session.is_valid() {
482        return Err("account changed before sending invite".to_string());
483    }
484    transport.publish(&wrap, &community.relays).await?;
485    Ok(wrap)
486}
487
488/// A minted public link: the shareable URL plus the addressable bundle event to
489/// publish and the link keypair to retain (in the Invite List) for later refresh
490/// or revocation.
491pub struct MintedLink {
492    pub url: String,
493    pub bundle_event: Event,
494    pub link_signer: Keys,
495    pub token: [u8; super::derive::TOKEN_LEN],
496}
497
498/// Mint a public invite link for this community: a fresh token + link keypair, the
499/// bundle encrypted under the token key and published at `(33301, link_signer,
500/// "")`, and the `base/invite/<naddr>#<fragment>` URL. `base` is the deep-link
501/// domain (e.g. `https://vectorapp.io`); the fragment carries the token + bootstrap
502/// relays and never reaches a server.
503pub async fn mint_public_link<T: Transport + ?Sized>(
504    transport: &T,
505    community: &CommunityV2,
506    base: &str,
507    expires_at_ms: Option<u64>,
508    label: Option<String>,
509) -> Result<MintedLink, String> {
510    let session = SessionGuard::capture();
511    let mut token = [0u8; super::derive::TOKEN_LEN];
512    token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
513    let link_signer = Keys::generate();
514    let bundle = bundle_of(community, Some(local_keys()?.public_key()), expires_at_ms, label.clone());
515    let bundle_key = super::derive::invite_bundle_key(&token);
516    let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
517    let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
518
519    if !session.is_valid() {
520        return Err("account changed before minting link".to_string());
521    }
522    transport.publish_durable(&bundle_event, &community.relays).await?;
523    let minted = MintedLink { url, bundle_event, link_signer, token };
524    // Sync the link across the creator's devices (13303) + publish the Registry
525    // (vsk-8) so members see the community is Public. Best-effort — the link works
526    // without the sync.
527    let _ = record_minted_link(transport, community, &minted).await;
528    // Local mirror so `list_public_invites` stays a sync local read (v1 parity);
529    // the 13303 list remains the cross-device record. Re-check the session: the
530    // publishes above straddled awaits, and this write must not land account A's
531    // link (secret token included) in a swapped-in account's DB.
532    if session.is_valid() {
533        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
534        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
535        let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
536    }
537    Ok(minted)
538}
539
540// ── The Invite Registry (vsk 8) + Invite List (13303), CORD-05 §4/§5 ──────────
541
542/// Fetch the creator's own 13303 Invite List from `relays` (newest wins; a
543/// decrypt/parse failure is "no news", never a clobber of the local mirror).
544async fn fetch_invite_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Option<invite::InviteList> {
545    let me = local_keys().ok()?;
546    let query = Query {
547        kinds: vec![super::kind::INVITE_LIST],
548        authors: vec![me.public_key().to_hex()],
549        limit: Some(4),
550        ..Default::default()
551    };
552    let events = transport.fetch(&query, relays).await.ok()?;
553    events
554        .into_iter()
555        .filter_map(|e| invite::parse_invite_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
556        .max_by_key(|(at, _)| *at)
557        .map(|(_, l)| l)
558}
559
560/// The creator's LIVE (non-tombstoned) link-signer pubkeys for one community — the
561/// Registry's content (CORD-05 §5), derived from the stored link secrets.
562fn live_signers_for(list: &invite::InviteList, community_id_hex: &str) -> Vec<PublicKey> {
563    let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
564    list.entries
565        .iter()
566        .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
567        .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
568        .collect()
569}
570
571/// Publish the creator's Registry (vsk-8) edition — their live link signers for this
572/// community — so members fold it into the Public/Private source of truth (a
573/// non-empty aggregate = Public).
574async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
575    let me = local_keys()?;
576    let eid = super::derive::invite_links_locator(community.id(), &me.public_key().to_bytes());
577    let content = invite::build_registry_content(live_signers);
578    publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content, None).await
579}
580
581/// Record a freshly-minted public link across the creator's devices: append it to the
582/// 13303 Invite List and refresh the Registry (CORD-05 §4/§5).
583async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
584    let session = SessionGuard::capture();
585    let me = local_keys()?;
586    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
587    let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
588    let mut list = fetch_invite_list(transport, &community.relays).await.unwrap_or_default();
589    if !list.entries.iter().any(|e| e.token == token_hex) {
590        list.entries.push(invite::InviteEntry {
591            token: token_hex,
592            signer_sk: minted.link_signer.secret_key().to_secret_hex(),
593            community_id: cid_hex.clone(),
594            url: minted.url.clone(),
595            label: None,
596            created_at: now_ms() / 1000,
597            expires_at: None,
598            extra: Default::default(),
599        });
600    }
601    if !session.is_valid() {
602        return Err("account changed during link record".to_string());
603    }
604    let event = invite::build_invite_list_event(&me, &list).map_err(|e| e.to_string())?;
605    transport.publish(&event, &community.relays).await?;
606    let signers = live_signers_for(&list, &cid_hex);
607    publish_invite_registry(transport, community, &session, &signers).await
608}
609
610/// Revoke a public link by its token hex (CORD-05 §2/§5): re-post its coordinate as a
611/// revocation tombstone (retiring the bundle behind the URL, so a fetcher finds the
612/// grave), tombstone the Invite List entry, and refresh the Registry. Retiring the
613/// LAST live link empties the Registry → the community reads Private (a Refounding is
614/// the owner's separate read-cut).
615pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
616    let session = SessionGuard::capture();
617    let me = local_keys()?;
618    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
619    let mut list = fetch_invite_list(transport, &community.relays).await.ok_or("no invite list found to revoke from")?;
620    let entry = list
621        .entries
622        .iter()
623        .find(|e| e.token == token_hex && e.community_id == cid_hex)
624        .cloned()
625        .ok_or("no such link in the invite list")?;
626    // Re-post the bundle coordinate as a revocation tombstone (creator-signed).
627    let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
628    let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
629    if !session.is_valid() {
630        return Err("account changed during revoke".to_string());
631    }
632    transport.publish_durable(&revocation, &community.relays).await?;
633    // Tombstone the Invite List entry (permanent — a stale device can't resurrect it).
634    list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
635    list.entries.retain(|e| e.token != token_hex);
636    let event = invite::build_invite_list_event(&me, &list).map_err(|e| e.to_string())?;
637    transport.publish(&event, &community.relays).await?;
638    let signers = live_signers_for(&list, &cid_hex);
639    publish_invite_registry(transport, community, &session, &signers).await?;
640    // Drop the local mirror row (sibling of the mint-time save) — only if still our session.
641    if session.is_valid() {
642        let _ = crate::db::community::delete_public_invite(token_hex);
643    }
644    Ok(())
645}
646
647/// Refresh every live public link's bundle behind its stable URL (CORD-05 §2) — e.g.
648/// after a Rekey/Refounding rolled the keys — by re-posting the bundle at the same
649/// coordinate with the CURRENT community state, so a link shared once keeps working
650/// across rotations. Best-effort.
651pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
652    let session = SessionGuard::capture();
653    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
654    // Fetch inline (not via fetch_invite_list) so a TRANSPORT FAILURE propagates as
655    // Err — the caller (a post-refounding refresh) must be able to retry, or live
656    // links keep serving the PRE-refound root and new joiners land on the dead
657    // epoch. A genuinely-empty list is Ok (nothing to refresh).
658    let me = local_keys()?;
659    let query = Query {
660        kinds: vec![super::kind::INVITE_LIST],
661        authors: vec![me.public_key().to_hex()],
662        limit: Some(4),
663        ..Default::default()
664    };
665    let events = transport.fetch(&query, &community.relays).await?;
666    let list = events
667        .into_iter()
668        .filter_map(|e| invite::parse_invite_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
669        .max_by_key(|(at, _)| *at)
670        .map(|(_, l)| l);
671    let Some(list) = list else {
672        return Ok(());
673    };
674    let creator = local_keys()?.public_key();
675    let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
676    for entry in &list.entries {
677        if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
678            continue;
679        }
680        let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
681        let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
682        let bundle = bundle_of(community, Some(creator), entry.expires_at, entry.label.clone());
683        let bundle_key = super::derive::invite_bundle_key(&token);
684        if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
685            if !session.is_valid() {
686                return Err("account changed during link refresh".to_string());
687            }
688            let _ = transport.publish_durable(&event, &community.relays).await;
689        }
690    }
691    Ok(())
692}
693
694/// Whether this community is PUBLIC (CORD-05 §5): fold every creator's Registry
695/// (vsk-8) that its author is authorized for (`CREATE_INVITE`, bound to their
696/// coordinate) into an aggregate live-link set — non-empty ⇒ a live link exists ⇒
697/// Public; empty ⇒ Private. Retiring the last link is what flips it back.
698pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
699    use crate::community::roles::Permissions;
700    use std::collections::BTreeMap;
701    let Ok(owner) = community.owner() else { return false };
702    let owner_hex = owner.to_hex();
703    let cid = community.id();
704    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
705    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
706        .unwrap_or_default()
707        .into_iter()
708        .filter(|(_, f)| f.0 == community.root_epoch.0)
709        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
710        .collect();
711    let control = control_group_key(&community.community_root, cid, community.root_epoch);
712    let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![control.pk_hex()], limit: Some(FOLLOW_PAGE), ..Default::default() };
713    let editions: Vec<ParsedEdition> = transport
714        .fetch(&query, &community.relays)
715        .await
716        .unwrap_or_default()
717        .iter()
718        .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(ed, _)| ed))
719        .collect();
720    let authority = fold_authority(community, &editions, &floors);
721
722    let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
723    for e in &editions {
724        if e.vsk == vsk::INVITE_LINKS {
725            by_eid.entry(e.entity_id).or_default().push(e);
726        }
727    }
728    for (eid, group) in &by_eid {
729        let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
730        let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
731        let head = group[hi];
732        // The creator must hold CREATE_INVITE AND own this coordinate.
733        if !authority.roles.is_authorized(&head.author.to_hex(), Some(&owner_hex), Permissions::CREATE_INVITE) {
734            continue;
735        }
736        if super::derive::invite_links_locator(cid, &head.author.to_bytes()) != *eid {
737            continue;
738        }
739        if invite::parse_registry_content(&head.content).map(|s| !s.is_empty()).unwrap_or(false) {
740            return true;
741        }
742    }
743    false
744}
745
746/// Accept an already-unwrapped bundle: verify the owner commitment AND that the
747/// delivered community_root is genuinely the owner's, persist the community, and
748/// announce a Guestbook Join (with invite attribution). Shared tail of both accept
749/// paths. Takes the caller's `SessionGuard` (captured BEFORE any network fetch the
750/// caller did) so the `is_valid()` gate straddles that I/O.
751async fn accept_bundle<T: Transport + ?Sized>(
752    transport: &T,
753    session: &SessionGuard,
754    bundle: &CommunityInvite,
755    invited_by: Option<PublicKey>,
756) -> Result<CommunityV2, String> {
757    let me = local_keys()?;
758    let at_ms = now_ms();
759    // Expiry gate: a past invite still previews but must not join (CORD-05 §1).
760    if bundle.expired(at_ms) {
761        return Err("this invite has expired".to_string());
762    }
763    // `from_bundle` re-validates bounds + the owner commitment fail-closed.
764    let community = CommunityV2::from_bundle(bundle, at_ms)?;
765
766    // Authenticate the delivered community_root before trusting it. The owner
767    // commitment proves WHO the owner is, but community_root (and channel keys) are
768    // NOT in that commitment, so a forged invite can pair a real (id, owner, salt)
769    // with an attacker-chosen root and silently partition the joiner onto planes
770    // only the attacker controls. Requiring the owner's genesis to open under the
771    // delivered root closes that eclipse; also reconciles channel classification.
772    let (community, join_heads) = verify_owner_root_and_reconcile(transport, community).await?;
773
774    // A dissolved community is a grave (CORD-02 §9): refuse to join it.
775    if is_dissolved(transport, &community).await {
776        return Err("this community has been dissolved".to_string());
777    }
778
779    // The account must not have swapped since the guard was captured (which was
780    // before any fetch the caller / the verify above performed) — else we'd write
781    // A's join into B.
782    if !session.is_valid() {
783        return Err("account changed during join".to_string());
784    }
785    // Seed the verified heads as the initial refuse-downgrade floor BEFORE the
786    // community row lands (floors-then-state, so a mid-seed error can't leave saved
787    // state outrunning its floor); the first post-join follow then can't persist a
788    // state below what this join already showed.
789    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
790    for h in &join_heads {
791        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)?;
792    }
793    crate::db::community::save_community_v2(&community)?;
794    // Archive the joined root at its epoch, so this member reads Public-channel
795    // history from their join epoch onward across later Refoundings (CORD-03 §3).
796    let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
797    // Same for each granted Private-channel key: the archive is what lets its
798    // history stay readable after the channel rotates away from this key.
799    for ch in &community.channels {
800        if let (true, Some(key)) = (ch.private, ch.key) {
801            let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
802        }
803    }
804
805    // Announce our Guestbook Join, echoing the invite attribution when present.
806    let attribution = invited_by
807        .map(|p| p.to_hex())
808        .or_else(|| bundle.creator_npub.clone())
809        .zip(Some(bundle.label.clone().unwrap_or_default()));
810    let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
811    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
812    let join_rumor = guestbook::build_join_rumor(me.public_key(), attr_ref, at_ms);
813    if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor(&join_rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000)) {
814        let _ = transport.publish(&join_wrap, &community.relays).await;
815    }
816
817    // Record the membership across devices (CORD-02 §8) — best-effort.
818    let _ = republish_community_list(transport, Some(community.id())).await;
819    Ok(community)
820}
821
822/// Prove the delivered `community_root` is genuinely the owner's, and reconcile
823/// channel classification from the owner's editions. `community_id` commits only
824/// to `(owner_xonly, owner_salt)` — both semi-public (they ride every bundle and
825/// every synced Community List) — so a forged invite can present a real community's
826/// id/owner/salt with an attacker-chosen root; every plane then derives from that
827/// root, silently eclipsing the joiner onto attacker-controlled addresses while the
828/// owner commitment still "verifies". The defense: the owner's genesis metadata
829/// edition (vsk-0, `eid == community_id`) only opens under the AUTHENTIC root — an
830/// attacker can't forge the owner's seal — so its presence on the control plane
831/// derived from the delivered root proves that root. Fail-closed: no owner genesis
832/// (forged invite, or relays unreachable) → refuse to join. On success, folds the
833/// owner's authoritative editions to heal a bundle that misclassified a channel.
834async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
835    transport: &T,
836    community: CommunityV2,
837) -> Result<(CommunityV2, Vec<FoldedHead>), String> {
838    let owner = community.owner()?;
839    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
840    let control_pk = control.pk_hex();
841
842    // AUTH-gating relays (ditto-relay's default gates kind-1059) serve a plane's
843    // wraps ONLY to a connection authenticated AS the stream key — Concord's
844    // group-addressed wraps aren't p-tagged to the joiner, so the login alone can't
845    // satisfy the gate and the control plane reads back empty. Register this
846    // community's stream keys + start the challenge responder so the fetch below
847    // (whose REQ triggers the relay's AUTH challenge) reads the plane after auth.
848    super::streamauth::prime(&community);
849
850    // Authenticity = the owner's GENESIS metadata edition (vsk-0, `eid ==
851    // community_id`) at the root-derived control plane. The genesis eid pins it to
852    // THIS community, and it lives ONLY under the real root — so a forged root can't
853    // produce one: an edition's seal carries no community binding, but another
854    // community's genesis has a different eid, and this community's own genesis is
855    // unreadable without its real root (which the forger lacks). ("Any owner edition"
856    // is NOT sound: an owner sig from any co-owned community, rewrapped onto the fake
857    // plane, would pass — reopening the eclipse.) The residual — a T-member replaying
858    // T's genesis onto a fake root to MITM another T-joiner — is closed only by
859    // binding the root into community_id (protocol, deferred).
860    //
861    // Seed `until` with a FAR-FUTURE constant (NOT now-based): `until.is_some()` takes
862    // the transport's AUTHORITATIVE drain-ALL-relays path (an open `until` returns only
863    // a fast relay's partial window and misses a genesis on a lagging relay — routine
864    // over Tor), while a constant beyond any real created_at clips NOTHING — so neither
865    // a clock-skewed future-dated genesis nor a >1h-slow-clock joiner is excluded (a
866    // now-based bound could clip either). Break on an EMPTY page (a short page is a
867    // relay cap). A forged root walks to exhaustion and rejects; a flood/deep plane
868    // that buries the genesis past the walk is the deferred protocol residual.
869    const PAGE: usize = 500;
870    const MAX_PAGES: usize = 4;
871    const FAR_FUTURE_SECS: u64 = 4_102_444_800; // ~year 2100 — above any real edition, safe as a relay `until`.
872    let mut editions: Vec<ParsedEdition> = Vec::new();
873    let mut found_genesis = false;
874    let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
875    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
876    for _ in 0..MAX_PAGES {
877        let query = Query {
878            kinds: vec![stream::KIND_WRAP],
879            authors: vec![control_pk.clone()],
880            until,
881            limit: Some(PAGE),
882            ..Default::default()
883        };
884        let wraps = transport.fetch(&query, &community.relays).await?;
885        // INCLUSIVE `until` + wrap-id dedup: a `-1` step can skip same-second
886        // siblings at a page boundary (and the genesis with them); re-served
887        // boundary events are free, and no-new-events means exhausted.
888        let mut oldest = u64::MAX;
889        let mut fresh = 0usize;
890        for w in &wraps {
891            if !seen_wraps.insert(w.id) {
892                continue;
893            }
894            fresh += 1;
895            oldest = oldest.min(w.created_at.as_secs());
896            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
897                if ed.author == owner {
898                    if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
899                        found_genesis = true;
900                    }
901                    editions.push(ed);
902                }
903            }
904        }
905        if found_genesis || fresh == 0 {
906            break; // authenticated (the owner genesis), or the relay is exhausted.
907        }
908        until = Some(oldest);
909    }
910    if !found_genesis {
911        return Err(
912            "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"
913                .to_string(),
914        );
915    }
916    // Join-time reconcile: the joiner holds no floors yet (empty map → bootstrap per
917    // entity). The heads this fold verified are returned for the caller to SEED as
918    // the initial floor once the community row is saved — without that, the first
919    // post-join follow would bootstrap floor-less and could persist a state BELOW
920    // what this join already verified and showed.
921    // Join-time reconcile folds only the owner's editions (genesis-authenticated
922    // above), and the owner is supreme — so owner-only authority suffices. The full
923    // roster (admins) folds on the first post-join follow_control.
924    let empty_floors = Floors::new();
925    let authority = AuthoritySet::owner_only();
926    let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
927    Ok((fold.updated.unwrap_or(community), fold.heads))
928}
929
930/// Accept a Direct Invite: unwrap the 3313 giftwrap (Schnorr-verifying the seal),
931/// then run the shared accept path. The recipient's consent IS this call. No
932/// network await precedes the accept, so the guard captured here suffices.
933pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
934    let session = SessionGuard::capture();
935    let me = local_keys()?;
936    let (inviter, bundle) = invite::unwrap_direct_invite(wrap, &me).map_err(|e| e.to_string())?;
937    accept_bundle(transport, &session, &bundle, Some(inviter)).await
938}
939
940/// Accept a PARKED Direct Invite from its stored bundle JSON (the wrap was already
941/// unwrapped + owner-verified at park time). Re-parses through the same fail-closed
942/// bundle validation, then runs the shared accept path (which re-verifies the owner
943/// root over the network). `inviter_hex` is the parked seal signer, for Guestbook
944/// Join attribution.
945pub async fn accept_parked_invite<T: Transport + ?Sized>(
946    transport: &T,
947    bundle_json: &str,
948    inviter_hex: Option<&str>,
949) -> Result<CommunityV2, String> {
950    let session = SessionGuard::capture();
951    let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
952    let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
953    accept_bundle(transport, &session, &bundle, invited_by).await
954}
955
956/// Accept a public invite link: parse it, fetch every event at `(33301,
957/// link_signer, "")`, and join. **Revocation is authoritative-if-present**: if
958/// ANY signer-valid tombstone is among the fetched events, refuse — never trust
959/// fetch ordering (a cross-relay union has no global newest-first sort, so a
960/// stale Live could otherwise win a partial-propagation race). Otherwise pick the
961/// newest valid Live by `created_at`.
962pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
963    // Capture BEFORE the network fetch so the join's is_valid() gate straddles it.
964    let session = SessionGuard::capture();
965    let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
966    let query = Query {
967        kinds: vec![super::kind::INVITE_BUNDLE],
968        authors: vec![parsed.link_signer.to_hex()],
969        d_tags: vec![String::new()],
970        ..Default::default()
971    };
972    let relays = if parsed.bootstrap_relays.is_empty() {
973        invite::stock_relays()
974    } else {
975        parsed.bootstrap_relays.clone()
976    };
977    let events = transport.fetch(&query, &relays).await?;
978    if !session.is_valid() {
979        return Err("account changed during join".to_string());
980    }
981    let bundle_key = super::derive::invite_bundle_key(&parsed.token);
982
983    // Scan EVERY event: a tombstone beats a Live unconditionally (order-independent).
984    let mut newest_live: Option<(u64, CommunityInvite)> = None;
985    for event in &events {
986        match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
987            Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
988            Ok(invite::BundleState::Live(bundle)) => {
989                let at = event.created_at.as_secs();
990                if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
991                    newest_live = Some((at, *bundle));
992                }
993            }
994            Err(_) => {} // a foreign/garbage event at the coordinate — ignore.
995        }
996    }
997    match newest_live {
998        Some((_, bundle)) => accept_bundle(transport, &session, &bundle, None).await,
999        None => Err("invite bundle not found on relays".to_string()),
1000    }
1001}
1002
1003/// Leave a community: publish a Guestbook Leave and tear down the local hold.
1004pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1005    let session = SessionGuard::capture();
1006    let me = local_keys()?;
1007    let at_ms = now_ms();
1008    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1009    let leave_rumor = guestbook::build_leave_rumor(me.public_key(), at_ms);
1010    if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor(&leave_rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000)) {
1011        let _ = transport.publish(&wrap, &community.relays).await;
1012    }
1013    if !session.is_valid() {
1014        return Err("account changed during leave".to_string());
1015    }
1016    // Tombstone the membership across devices (CORD-02 §8) BEFORE the local delete,
1017    // to the leaving community's own relays (it's about to be gone locally) —
1018    // best-effort.
1019    let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
1020    // The tombstone publish straddled an await — never delete from a swapped-in DB.
1021    if !session.is_valid() {
1022        return Err("account changed during leave".to_string());
1023    }
1024    crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1025    Ok(())
1026}
1027
1028/// Cooperative Kick (CORD-04 §6, Guestbook plane): name the target; every reader
1029/// honors it iff the signer holds KICK and strictly outranks them (the coalesce's
1030/// `can_kick`), so publishing without authority is inert. A kicked member may
1031/// rejoin with a fresh invite — cryptographic severance is the ban/refound path.
1032pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1033    let session = SessionGuard::capture();
1034    let me = local_keys()?;
1035    // Fast local pre-check; readers re-verify independently. The `vac` citation
1036    // rides with the deferred citation-completeness pass (owner needs none).
1037    let authority = fetch_authority(transport, community).await;
1038    let owner_hex = community.owner()?.to_hex();
1039    if !authority.roles.can_act_on_member(
1040        &me.public_key().to_hex(),
1041        Some(&owner_hex),
1042        &target.to_hex(),
1043        crate::community::roles::Permissions::KICK,
1044    ) {
1045        return Err("not authorized to kick this member".to_string());
1046    }
1047    let at_ms = now_ms();
1048    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1049    let rumor = guestbook::build_kick_rumor(me.public_key(), *target, None, at_ms);
1050    let (wrap, _) = guestbook::seal_guestbook_rumor(&rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000))
1051        .map_err(|e| e.to_string())?;
1052    if !session.is_valid() {
1053        return Err("account changed before send".to_string());
1054    }
1055    transport.publish(&wrap, &community.relays).await?;
1056    Ok(())
1057}
1058
1059/// A community's folded, delegation-authorized authority — the on-demand read
1060/// view (a paged control-plane fetch + fold, nothing persisted). `roles` is the
1061/// owner-seeded authorized roster (shared algebra with v1); `banned` the
1062/// enforced banlist. `floored`/`head_entities` let a writer detect a WITHHELD
1063/// entity (floored locally but no head folded) before replacing it blind.
1064pub struct AuthorityView {
1065    pub roles: crate::community::roles::CommunityRoles,
1066    pub banned: std::collections::BTreeSet<String>,
1067    /// Any authority entity's fold hit a floor gap (withheld / evicted link).
1068    pub gapped: bool,
1069    /// Entity hexes holding a persisted floor at this epoch (all vsk kinds).
1070    pub floored: std::collections::BTreeSet<String>,
1071    /// Authority entities (role/grant/banlist) that folded a head this fetch.
1072    pub head_entities: std::collections::BTreeSet<String>,
1073}
1074
1075/// Fetch + fold the community's current authority (CORD-04), paging older like
1076/// `follow_control` while the fold is gapped so a busy control plane can't push
1077/// the roster off the newest window. A fetch failure degrades fail-safe:
1078/// owner-only authority plus the PERSISTED banlist — nobody gains standing from
1079/// an outage, and a ban never lifts on withheld data.
1080pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1081    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1082    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1083        .unwrap_or_default()
1084        .into_iter()
1085        .filter(|(_, f)| f.0 == community.root_epoch.0)
1086        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1087        .collect();
1088    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1089
1090    let mut editions: Vec<ParsedEdition> = Vec::new();
1091    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1092    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1093    let mut oldest: Option<u64> = None;
1094    let mut until: Option<u64> = None;
1095    // Seed from an EMPTY fold, not owner_only(): a fold over zero editions yields
1096    // owner-only roles AND retains the PERSISTED banlist. So a first-page transport
1097    // error returns the stored bans (fail-safe), never an empty banlist that would
1098    // silently un-ban on withheld data.
1099    let mut a = fold_authority(community, &[], &floors);
1100    for _ in 0..FOLLOW_MAX_PAGES {
1101        let query = Query {
1102            kinds: vec![stream::KIND_WRAP],
1103            authors: vec![control.pk_hex()],
1104            until,
1105            limit: Some(FOLLOW_PAGE),
1106            ..Default::default()
1107        };
1108        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
1109        let mut fresh = 0usize;
1110        for w in &wraps {
1111            if !seen_wraps.insert(w.id) {
1112                continue;
1113            }
1114            fresh += 1;
1115            let at = w.created_at.as_secs();
1116            if oldest.is_none_or(|o| at < o) {
1117                oldest = Some(at);
1118            }
1119            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1120                if seen.insert(ed.inner_id) {
1121                    editions.push(ed);
1122                }
1123            }
1124        }
1125        a = fold_authority(community, &editions, &floors);
1126        if !a.gapped || fresh == 0 {
1127            break;
1128        }
1129        until = oldest;
1130    }
1131    AuthorityView {
1132        roles: a.roles,
1133        banned: a.banned,
1134        gapped: a.gapped,
1135        floored: floors.keys().cloned().collect(),
1136        head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
1137    }
1138}
1139
1140/// Fold the Complete Memberlist from the Guestbook plane. The proven owner is
1141/// ALWAYS a member (derived from the self-certifying community_id — no network,
1142/// so a lost/evicted genesis Join can't drop them). Observed authors — anyone
1143/// seen publishing on a channel — are folded in FORWARD-only per CORD-02 §5, so a
1144/// member whose Join was lost still counts.
1145pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
1146    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1147    // PAGE the Guestbook (bounded until-walk + wrap-id dedup): a single 500-window
1148    // silently drops a member whose Join aged out (organic growth, or an insider
1149    // flooding throwaway Joins), and `refound_community` consumes this list as its
1150    // rekey recipient set — a dropped member is SEVERED. Beyond this depth a
1151    // community needs sharding (documented); the granted-member union below is the
1152    // consensus-complete backstop regardless of Guestbook depth.
1153    const GB_PAGE: usize = 500;
1154    const GB_MAX_PAGES: usize = 12;
1155    let owner = community.owner()?;
1156    let mut events = Vec::new();
1157    let mut seen: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1158    let mut until: Option<u64> = None;
1159    let mut oldest: Option<u64> = None;
1160    for _ in 0..GB_MAX_PAGES {
1161        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_group.pk_hex()], until, limit: Some(GB_PAGE), ..Default::default() };
1162        let wraps = transport.fetch(&query, &community.relays).await?;
1163        let mut fresh = 0usize;
1164        for wrap in &wraps {
1165            if !seen.insert(wrap.id) {
1166                continue;
1167            }
1168            fresh += 1;
1169            let at = wrap.created_at.as_secs();
1170            if oldest.is_none_or(|o| at < o) {
1171                oldest = Some(at);
1172            }
1173            if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
1174                if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
1175                    events.push(ev);
1176                }
1177            }
1178        }
1179        if fresh == 0 || wraps.len() < GB_PAGE {
1180            break;
1181        }
1182        match oldest {
1183            Some(o) if o > 0 => until = Some(o),
1184            _ => break,
1185        }
1186    }
1187    // Observed authors: fold each held channel's recent authorship (real author +
1188    // newest ms), so a member who posted but whose Join was lost is still counted.
1189    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
1190    for ch in &community.channels {
1191        if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
1192            for f in &page {
1193                let e = observed.entry(f.event.opened().author).or_insert(0);
1194                *e = (*e).max(f.event.opened().at_ms);
1195            }
1196        }
1197    }
1198
1199    // Fold the Control Plane roster + banlist (CORD-04) for Kick authority and the
1200    // ban subtraction. A control fetch failure degrades to owner-only authority + no
1201    // bans (fail-open on availability is safe here: a Kick still needs a real signer,
1202    // and a missed ban only fails to HIDE, never to wrongly admit authority).
1203    let authority = fetch_authority(transport, community).await;
1204    let owner_hex = owner.to_hex();
1205
1206    // CONSENSUS-COMPLETE backstop: every member the folded roster GRANTS a role to
1207    // is provably a member (a Grant binds member_xonly, CORD-02 A.6) — count them
1208    // even if their Join aged out of the Guestbook entirely and they never posted.
1209    // This is what keeps a Refounding from severing a lurking admin. `observed`
1210    // carries them at ts 0 (presence, not recency); the banlist subtraction below
1211    // still removes a banned grantee whose grant wasn't yet stripped.
1212    for g in &authority.roles.grants {
1213        if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
1214            observed.entry(pk).or_insert(0);
1215        }
1216    }
1217
1218    // Snapshot authority (CORD-02 §5): a refounding rolls `root_epoch` and re-seeds the
1219    // new epoch's Guestbook with a 3312 snapshot of the survivors. Refounding is OWNER-only,
1220    // so the owner is the refounder whose snapshot is honored — without this, every silent
1221    // survivor vanishes from the memberlist until they re-post. A genesis community
1222    // (root_epoch 0) has no refounder, hence no snapshot power.
1223    let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1224    // Kick authority (CORD-04 §6): the signer must hold KICK AND strictly outrank the
1225    // target (the owner is supreme; equal cannot kick equal).
1226    let can_kick = |actor: &PublicKey, target: &PublicKey| {
1227        authority
1228            .roles
1229            .can_act_on_member(&actor.to_hex(), Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1230    };
1231    let coalesced = guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick);
1232    // The authorized banlist, as pubkeys (a malformed hex entry is simply dropped).
1233    let banlist: std::collections::BTreeSet<PublicKey> =
1234        authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
1235    let mut members = guestbook::complete_memberlist(&coalesced, &observed, &banlist);
1236    // The owner is a member by definition, independent of any fetched Join.
1237    if !banlist.contains(&owner) {
1238        members.insert(owner);
1239    }
1240    Ok(members.into_iter().collect())
1241}
1242
1243// ── Dissolution (CORD-02 §9) ─────────────────────────────────────────────────
1244
1245/// Owner dissolution / "Delete Community" (CORD-02 §9): publish the terminal
1246/// tombstone at the dissolved plane (`community_id`-derived, epoch-free, so every
1247/// past or present member resolves the same grave and a Refounding can never strand
1248/// it). The tombstone's presence IS the state; only the owner's seal counts.
1249/// Irreversible — on success the local hold is sealed read-only.
1250pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1251    let session = SessionGuard::capture();
1252    let me = local_keys()?;
1253    if community.owner()? != me.public_key() {
1254        return Err("only the owner can dissolve a community".to_string());
1255    }
1256    let at = now_ms() / 1000;
1257    let rumor = super::dissolution::dissolved_tombstone_rumor(me.public_key(), community.id(), at);
1258    let wrap = super::dissolution::seal_dissolved(&rumor, community.id(), &me, Timestamp::from_secs(at)).map_err(|e| e.to_string())?;
1259    if !session.is_valid() {
1260        return Err("account changed during dissolve".to_string());
1261    }
1262    // Durable broadcast: death must propagate (a rekey racing a dissolution loses).
1263    transport.publish_durable(&wrap, &community.relays).await?;
1264    crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1265    Ok(())
1266}
1267
1268/// Whether a valid owner-signed dissolution tombstone exists for this community on
1269/// its relays (CORD-02 §9). A join refuses a dead community, and a live follow seals
1270/// on sight. Fail-OPEN on a fetch error (absence of proof is not death), but any
1271/// owner-verified tombstone found is authoritative.
1272pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1273    let group = super::derive::dissolved_group_key(community.id());
1274    let query = Query {
1275        kinds: vec![stream::KIND_WRAP],
1276        authors: vec![group.pk_hex()],
1277        limit: Some(20),
1278        ..Default::default()
1279    };
1280    let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
1281        return false;
1282    };
1283    wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
1284}
1285
1286// ── Refounding (CORD-06 §3) ──────────────────────────────────────────────────
1287
1288/// Owner/admin Refounding (CORD-06 §3): roll the `community_root` to
1289/// cryptographically remove `removed` from a Private community (a Ban's read-cut).
1290/// Compacts the Control Plane under the new root (re-wraps each head VERBATIM — the
1291/// inner owner/actor signatures survive, so no re-authoring), rekeys the base plus
1292/// every Private channel (each sealed under the PRIOR root, D2, so a base-fork loser
1293/// can still open them), and seeds the new epoch's Guestbook snapshot. Requires BAN.
1294///
1295/// **Acquire-before-commit:** the compaction is fetched + re-sealed BEFORE any
1296/// publish, and a head we can't fetch ABORTS with ZERO published state — so a
1297/// transient miss never strands a published rekey with a half-anchored plane.
1298pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
1299    let session = SessionGuard::capture();
1300    let cid = community.id();
1301    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1302    // Death wins every race: a dissolved community never re-founds (CORD-02 §9).
1303    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
1304        return Err("this community has been dissolved; it cannot be re-founded".to_string());
1305    }
1306    let me = local_keys()?;
1307    // Serialize with the follow worker for the whole rotation: the commit tail
1308    // whole-row-saves, and an unserialized concurrent follow could otherwise be
1309    // rolled back (or adopt a half-published sibling of this very rotation).
1310    let lock = super::realtime::follow_lock(cid);
1311    let _guard = lock.lock().await;
1312    // Reload the FRESHEST base state: a stale caller struct would address the rotation
1313    // under a superseded root (a base fork with no heal). The community_id is
1314    // self-certifying + stable, so re-loading by it is safe.
1315    let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
1316    let community = &fresh;
1317    let owner = community.owner()?;
1318
1319    // OWNER-ONLY send: the receive counterpart (`advance_scope`) honors ONLY the
1320    // owner's rotation, so a non-owner BAN-holder's Refounding would fork onto a root
1321    // nobody follows and fail to sever the target. A non-owner's ban still silences
1322    // (Banlist) + strips authority (Grant); the read-cut is the owner's action alone
1323    // (CORD-06 §3 partial-removal degradation). Owner ⊃ BAN (supreme), so this is the
1324    // stricter gate.
1325    if me.public_key() != owner {
1326        return Err("only the owner can re-found (the cryptographic read-cut)".to_string());
1327    }
1328
1329    // Fold the current roster: the opened editions are reused for the compaction (their
1330    // seals re-wrap under the new epoch), and the roster gates which admin-authored
1331    // heads carry forward.
1332    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
1333        .into_iter()
1334        .filter(|(_, f)| f.0 == community.root_epoch.0)
1335        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1336        .collect();
1337    let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
1338    // Page the ENTIRE control plane, not just the newest window: the compaction MUST
1339    // carry EVERY committed (floored) entity to the new epoch, so a head buried under a
1340    // flood of newer editions (100 roles + 400 grants already exceeds one page) or a
1341    // head a relay withholds can't silently drop. CORD-06 §3 mandates aborting if the
1342    // Refounder cannot fold all Control Events — a dropped Banlist would unban a member
1343    // at the new epoch a fresh joiner bootstraps.
1344    let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
1345    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1346    let mut oldest: Option<u64> = None;
1347    let mut until: Option<u64> = None;
1348    for _ in 0..FOLLOW_MAX_PAGES {
1349        let query = Query {
1350            kinds: vec![stream::KIND_WRAP],
1351            authors: vec![current_control.pk_hex()],
1352            until,
1353            limit: Some(FOLLOW_PAGE),
1354            ..Default::default()
1355        };
1356        let wraps = transport.fetch(&query, &community.relays).await?;
1357        let mut fresh = 0usize;
1358        for w in &wraps {
1359            if !seen_wraps.insert(w.id) {
1360                continue;
1361            }
1362            fresh += 1;
1363            let at = w.created_at.as_secs();
1364            if oldest.is_none_or(|o| at < o) {
1365                oldest = Some(at);
1366            }
1367            if let Ok(parsed) = control::open_control_edition(w, &current_control) {
1368                opened.push(parsed);
1369            }
1370        }
1371        // Page until every committed entity has its editions in hand (raw coverage),
1372        // so the floor-driven compaction below can fold each head.
1373        let present: std::collections::HashSet<String> =
1374            opened.iter().map(|(e, _)| crate::simd::hex::bytes_to_hex_32(&e.entity_id)).collect();
1375        if floors.keys().all(|k| present.contains(k)) || fresh == 0 {
1376            break;
1377        }
1378        until = oldest;
1379    }
1380
1381    let prev_epoch = community.root_epoch;
1382    let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
1383    let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
1384    // Mint-or-REUSE the new root, keyed by (scope, new_epoch) and archived BEFORE any
1385    // publish: a retried Refounding re-delivers the SAME root at this epoch/address, so
1386    // it can't double-mint two roots a receiver's correlation dedup would collapse into
1387    // a permanent fork (CORD-06 §3 idempotency).
1388    let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
1389    let new_control = control_group_key(&new_root, cid, new_epoch);
1390    let at = now_ms();
1391    let at_secs = at / 1000;
1392
1393    // ACQUIRE + COVERAGE GATE (CORD-06 §3 MUST): re-wrap the head of EVERY committed
1394    // (floored) entity under the new epoch — FLOOR-driven, so nothing silently drops,
1395    // including entities the metadata/roster folds don't touch (the invite Registry
1396    // vsk-8, whose coordinate survives the rekey per CORD-05 §5). A floor whose head
1397    // can't be folded (buried past the pager / withheld) ABORTS before any publish.
1398    use std::collections::BTreeMap;
1399    let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
1400    for (i, (e, _)) in opened.iter().enumerate() {
1401        by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
1402    }
1403    let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
1404    for (floor_key, floor) in &floors {
1405        // Re-wrap the AUTHORIZED head — the exact edition the persisted floor commits to
1406        // (its self_hash). The floor advances ONLY to authorized heads (author-aware fold),
1407        // so matching it is authority-correct across EVERY entity type. `fold_head`'s
1408        // version-chain TIP is author-BLIND: a member can seal a forged higher-version
1409        // edition chaining onto the floor, which the tip would carry and honest folders
1410        // then DROP as unauthorized — silently suppressing that role/grant/banlist across
1411        // the refounding. Abort if the committed head isn't served (fail-closed).
1412        let head_idx = by_eid
1413            .get(floor_key)
1414            .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
1415        let Some(head_idx) = head_idx else {
1416            return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
1417        };
1418        let (head_ed, head_os) = &opened[head_idx];
1419        let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
1420        let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
1421        carried.push((h, rewrapped));
1422    }
1423    if !session.is_valid() {
1424        return Err("account changed during re-founding acquire".to_string());
1425    }
1426
1427    // Recipients: the current members minus `removed`, plus me (multi-device).
1428    let members = memberlist(transport, community).await?;
1429    let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
1430    let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
1431    if !recipients.iter().any(|p| *p == me.public_key()) {
1432        recipients.push(me.public_key());
1433    }
1434
1435    // Base rekey blobs (the new root to each recipient), sealed under the PRIOR root.
1436    let mut base_blobs = Vec::new();
1437    for r in &recipients {
1438        base_blobs.push(
1439            super::rekey::build_blob_local(me.secret_key(), &me.public_key().to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
1440                .map_err(|e| e.to_string())?,
1441        );
1442    }
1443    let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
1444    let base_chunks =
1445        super::rekey::build_rekey_chunks_local(&me, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs)
1446            .map_err(|e| e.to_string())?;
1447
1448    // Private-channel rekeys: each mints a fresh key at its next channel-epoch, sealed
1449    // under the PRIOR root (D2). Public channels ride the base — no per-channel rekey.
1450    let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
1451    let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
1452    for ch in &community.channels {
1453        let (Some(old_key), true) = (ch.key, ch.private) else { continue };
1454        let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
1455        // Mint-or-reuse per channel too, keyed by (channel_id, next epoch) — same
1456        // retry-idempotency as the base root.
1457        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)?;
1458        let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
1459        let mut ch_blobs = Vec::new();
1460        for r in &recipients {
1461            ch_blobs.push(
1462                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)
1463                    .map_err(|e| e.to_string())?,
1464            );
1465        }
1466        let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
1467        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)
1468            .map_err(|e| e.to_string())?;
1469        channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
1470        channel_chunk_sets.push(ch_chunks);
1471    }
1472    if !session.is_valid() {
1473        return Err("account changed during re-founding prepare".to_string());
1474    }
1475
1476    // COMMIT (durable publishes only — all fetching is done). Base rekey first
1477    // (delivers the new root), then channel rekeys, then the compacted control.
1478    for c in &base_chunks {
1479        transport.publish_durable(c, &community.relays).await?;
1480    }
1481    for set in &channel_chunk_sets {
1482        for c in set {
1483            transport.publish_durable(c, &community.relays).await?;
1484        }
1485    }
1486    for (_, wrap) in &carried {
1487        transport.publish_durable(wrap, &community.relays).await?;
1488    }
1489    // Guestbook snapshot at the new epoch — best-effort (a Refounding succeeds without
1490    // it; an omitted member heals by publishing their own Join).
1491    let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
1492    let snap_id = crate::community::random_32();
1493    for rumor in guestbook::build_snapshot_rumors(me.public_key(), &recipients, snap_id, at) {
1494        if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor(&rumor, &gb_group, &me, Timestamp::from_secs(at_secs)) {
1495            let _ = transport.publish(&wrap, &community.relays).await;
1496        }
1497    }
1498
1499    // COMMIT locally, only now that the new root + compacted plane are on relays.
1500    if !session.is_valid() {
1501        return Err("account changed during re-founding commit".to_string());
1502    }
1503    if crate::db::community::community_protocol(cid)?.is_none() {
1504        return Ok(community.clone()); // left/deleted mid-rotation — don't resurrect.
1505    }
1506    // Save the new root/epoch + rekeyed channel keys in ONE tx FIRST, so a crash can
1507    // never leave the base root advanced while the channel keys lag (which would
1508    // re-derive the channel rekey address under the wrong root and orphan them).
1509    let mut updated = community.clone();
1510    updated.community_root = new_root;
1511    updated.root_epoch = new_epoch;
1512    for (id, key, ep) in &channel_updates {
1513        if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
1514            c.key = Some(*key);
1515            c.epoch = *ep;
1516        }
1517    }
1518    crate::db::community::save_community_v2(&updated)?;
1519    // Archive the new epoch key + confirm the monotonic base head (the root was already
1520    // archived by mint_or_reuse, so this is idempotent). Record the carried heads at
1521    // the NEW epoch; if a crash skips this, the epoch-filtered floors bootstrap the
1522    // compacted control on the next follow, so they self-heal.
1523    crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
1524    for (h, _) in &carried {
1525        crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
1526    }
1527    // Refresh any live public links so their bundles carry the NEW root behind the
1528    // same URL (a link shared once survives the rotation, CORD-05 §2). Idempotent,
1529    // so retry a transient failure — a stranded link lands a new joiner on the dead
1530    // pre-refound epoch, and there's no other trigger to heal it before the next
1531    // refounding. A persistent failure is logged (refound already succeeded).
1532    for attempt in 0..3u8 {
1533        match refresh_public_links(transport, &updated).await {
1534            Ok(()) => break,
1535            Err(_) if !session.is_valid() => break, // swapped — stop touching this account
1536            Err(e) if attempt == 2 => {
1537                crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
1538            }
1539            Err(_) => continue,
1540        }
1541    }
1542    Ok(updated)
1543}
1544
1545/// Mint a fresh 32-byte rotation key for `(scope, new_epoch)`, or REUSE the one
1546/// already archived from a prior (aborted) attempt — so a retried Refounding re-
1547/// delivers the SAME key at the same epoch/address instead of double-minting two roots
1548/// a receiver's correlation dedup would collapse into a permanent fork (CORD-06 §3
1549/// idempotency). Archived BEFORE the first publish; `scope` is the all-zero server-root
1550/// sentinel for a base rotation, else the channel_id hex.
1551fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
1552    if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
1553        return Ok(existing);
1554    }
1555    let fresh = crate::community::random_32();
1556    crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
1557    Ok(fresh)
1558}
1559
1560// ── The Community List (kind 13302, CORD-02 §8) ──────────────────────────────
1561
1562/// This community's MEMBERSHIP subset for the 13302 list (CORD-02 §8): never the
1563/// icon (a rehydrating device folds it from the Control Plane), never the link
1564/// fields. Only PRIVATE channel keys ride — public channels derive from the root.
1565fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
1566    let hex = crate::simd::hex::bytes_to_hex_32;
1567    let channels = community
1568        .channels
1569        .iter()
1570        .filter(|c| c.private)
1571        .filter_map(|c| {
1572            c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: hex(&k), epoch: c.epoch.0, name: c.name.clone() })
1573        })
1574        .collect();
1575    super::list::JoinMaterial {
1576        community_id: hex(&community.identity.community_id.0),
1577        owner: hex(&community.identity.owner_xonly),
1578        owner_salt: hex(&community.identity.owner_salt),
1579        community_root: hex(&community.community_root),
1580        root_epoch: community.root_epoch.0,
1581        channels,
1582        relays: community.relays.clone(),
1583        name: community.name.clone(),
1584        extra: Default::default(),
1585    }
1586}
1587
1588/// Rebuild an invite bundle from list join material, for a cross-device rehydrate
1589/// (the material IS the membership subset of a bundle). The owner root is still
1590/// verified over the network before the community is trusted (accept_bundle).
1591fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
1592    let channels = jm
1593        .channels
1594        .iter()
1595        .map(|c| invite::ChannelGrant { id: c.id.clone(), key: c.key.clone(), epoch: c.epoch, name: c.name.clone() })
1596        .collect();
1597    CommunityInvite {
1598        community_id: jm.community_id.clone(),
1599        owner: jm.owner.clone(),
1600        owner_salt: jm.owner_salt.clone(),
1601        community_root: jm.community_root.clone(),
1602        root_epoch: jm.root_epoch,
1603        channels,
1604        relays: jm.relays.clone(),
1605        name: jm.name.clone(),
1606        icon: None,
1607        expires_at: None,
1608        creator_npub: None,
1609        label: None,
1610        extra: Default::default(),
1611    }
1612}
1613
1614/// The union of every held v2 community's relays — where this account's 13302 list
1615/// lives (a fresh device that opens any held community reaches the same set).
1616fn held_v2_relays() -> Vec<String> {
1617    let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1618    if let Ok(ids) = crate::db::community::list_community_ids() {
1619        for id in ids {
1620            if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
1621                if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
1622                    set.extend(c.relays);
1623                }
1624            }
1625        }
1626    }
1627    set.into_iter().collect()
1628}
1629
1630/// Fetch this account's own 13302 Community List from `relays` (the newest wins;
1631/// a decrypt/parse failure is "no news", never a clobber of the local mirror).
1632/// Fetch this account's newest 13302 list. `Err` = the transport FAILED (a caller
1633/// must NOT drive a replaceable-event write from a failed read — it would clobber
1634/// the live list); `Ok(None)` = genuinely no list yet; `Ok(Some)` = the list.
1635async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
1636    let me = local_keys()?;
1637    let query = Query {
1638        kinds: vec![super::kind::COMMUNITY_LIST],
1639        authors: vec![me.public_key().to_hex()],
1640        limit: Some(4),
1641        ..Default::default()
1642    };
1643    let events = transport.fetch(&query, relays).await?;
1644    Ok(events
1645        .into_iter()
1646        .filter_map(|e| super::list::parse_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
1647        .max_by_key(|(at, _)| *at)
1648        .map(|(_, l)| l))
1649}
1650
1651/// Rebuild this account's 13302 from its held v2 communities, MERGE with the remote
1652/// copy (preserving tombstones, other-device entries, unknown fields), and publish.
1653/// `just_joined` is the community THIS call is recording a create/join for — the
1654/// ONLY community whose entry is (re)stamped `now`, so it beats any prior tombstone
1655/// (a deliberate re-join resurrects). Every OTHER held community that the remote
1656/// has tombstoned is left tombstoned (a sibling device's leave is NOT undone just
1657/// because we joined something else — the W1 resurrection hole). Idempotent;
1658/// best-effort — a list-publish failure never fails the membership change itself.
1659pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<(), String> {
1660    let session = SessionGuard::capture();
1661    let me = local_keys()?;
1662    let relays = held_v2_relays();
1663    if relays.is_empty() {
1664        return Ok(()); // nothing held → nothing to sync
1665    }
1666    // A FAILED remote fetch must not drive this replaceable-event write: publishing
1667    // a list built without the remote seeds would drop older-epoch backfill anchors
1668    // and re-stamp add-times (the W2 seed-regression + a resurrection window).
1669    let remote = match fetch_community_list(transport, &relays).await {
1670        Ok(r) => r.unwrap_or_default(),
1671        Err(_) => return Ok(()),
1672    };
1673    let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
1674    let now = now_ms();
1675    let mut local = super::list::CommunityList::default();
1676    for id in crate::db::community::list_community_ids()? {
1677        if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
1678            continue;
1679        }
1680        let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
1681        let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
1682        let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
1683        // A held community the remote has tombstoned (a sibling device left it) that
1684        // we are NOT currently (re)joining stays LEFT — don't re-add it, or joining a
1685        // different community would silently undo the leave everywhere.
1686        if !is_join && !remote.is_live(&cid_hex) && remote.tombstones.iter().any(|t| t.community_id == cid_hex) {
1687            continue;
1688        }
1689        // Keep an already-live entry's add time (no churn); the joined community (or a
1690        // genuinely-new one) stamps `now` so a re-join beats a stale tombstone.
1691        let added_at = if remote.is_live(&cid_hex) && !is_join {
1692            remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
1693        } else {
1694            now
1695        };
1696        let jm = join_material(&c);
1697        local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
1698    }
1699    let merged = remote.merge(&local);
1700    merged.assert_fits().map_err(|e| e.to_string())?;
1701    let event = super::list::build_list_event(&me, &merged).map_err(|e| e.to_string())?;
1702    if !session.is_valid() {
1703        return Err("account changed during community-list publish".to_string());
1704    }
1705    transport.publish(&event, &relays).await
1706}
1707
1708/// Record a permanent leave tombstone for `community_id` in the 13302, published to
1709/// `relays` (the leaving community's own, since it's about to be deleted locally).
1710async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
1711    let me = local_keys()?;
1712    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
1713    // A failed fetch here would drop other communities' entries (only the
1714    // tombstone would survive); preserve them by bailing — the leave re-records
1715    // on the next attempt, and the local teardown already happened.
1716    let mut doc = match fetch_community_list(transport, relays).await {
1717        Ok(d) => d.unwrap_or_default(),
1718        Err(e) => return Err(e),
1719    };
1720    let now = now_ms();
1721    doc.tombstones.retain(|t| t.community_id != cid_hex);
1722    doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
1723    doc.assert_fits().map_err(|e| e.to_string())?;
1724    let event = super::list::build_list_event(&me, &doc).map_err(|e| e.to_string())?;
1725    transport.publish(&event, relays).await
1726}
1727
1728/// Sync memberships from the 13302 across devices: fetch this account's list from
1729/// `bootstrap_relays` (its held communities' relays plus any caller-supplied set for
1730/// a fresh device), and JOIN every live entry not already held — reconstructing the
1731/// community from its join material and re-verifying the owner root. Returns the
1732/// newly-rehydrated communities (so the caller can subscribe + notify).
1733pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<Vec<CommunityV2>, String> {
1734    let session = SessionGuard::capture();
1735    let mut relays = held_v2_relays();
1736    relays.extend(bootstrap_relays.iter().cloned());
1737    relays.sort();
1738    relays.dedup();
1739    if relays.is_empty() {
1740        return Ok(vec![]);
1741    }
1742    let list = match fetch_community_list(transport, &relays).await {
1743        Ok(Some(l)) => l,
1744        Ok(None) | Err(_) => return Ok(vec![]),
1745    };
1746    // Receive-side teardown (the counterpart to the republish tombstone guard):
1747    // a community this device still holds but the synced list shows TOMBSTONED (a
1748    // sibling device left it) and NOT live gets torn down here, so a leave on one
1749    // device propagates to the others. A re-join would have re-added it live
1750    // (beating the tombstone), so is_live short-circuits the honest case.
1751    for t in &list.tombstones {
1752        if list.is_live(&t.community_id) {
1753            continue;
1754        }
1755        let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
1756        let id = crate::community::CommunityId(cid);
1757        if crate::db::community::load_community_v2(&id).ok().flatten().is_none() {
1758            continue; // not held — nothing to tear down
1759        }
1760        if !session.is_valid() {
1761            return Err("account changed during community-list sync".to_string());
1762        }
1763        let _ = crate::db::community::delete_community(&t.community_id);
1764    }
1765    let mut joined = Vec::new();
1766    for entry in list.live_entries() {
1767        let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
1768        if crate::db::community::load_community_v2(&crate::community::CommunityId(cid)).ok().flatten().is_some() {
1769            continue; // already held
1770        }
1771        if !session.is_valid() {
1772            return Err("account changed during community-list sync".to_string());
1773        }
1774        // The material IS a bundle; accept_bundle re-verifies the owner root, saves,
1775        // seeds floors, and announces our Join (idempotent for an existing member).
1776        let bundle = material_to_invite(&entry.current);
1777        if let Ok(community) = accept_bundle(transport, &session, &bundle, None).await {
1778            joined.push(community);
1779        }
1780    }
1781    Ok(joined)
1782}
1783
1784// ── Control edition authoring (CORD-04 roles / CORD-02 §6 / CORD-03 §2) ──────
1785
1786/// Publish one control edition (a role, grant, banlist, community-metadata, or
1787/// channel-metadata edit) at the next version for its entity, chaining `prev` from
1788/// our held head, and advance our local floor. Authority is enforced by every
1789/// reader's roster fold (CORD-04 §5: authority is rejection, not prevention), so this
1790/// requires only a valid local signer; a well-behaved client checks its own rank
1791/// first, but a reader drops an unauthorized edition regardless.
1792async fn publish_control_edition<T: Transport + ?Sized>(
1793    transport: &T,
1794    community: &CommunityV2,
1795    session: &SessionGuard,
1796    vsk: &str,
1797    entity_id: &[u8; 32],
1798    content: &str,
1799    citation: Option<&crate::community::edition::AuthorityCitation>,
1800) -> Result<(), String> {
1801    let me = local_keys()?;
1802    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1803    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1804    let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
1805    let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
1806        Some((v, h)) => (v + 1, Some(h)),
1807        None => (1, None),
1808    };
1809    let at = now_ms() / 1000;
1810    let rumor = control::build_edition_rumor(me.public_key(), vsk, entity_id, version, prev.as_ref(), content, at, citation);
1811    let (wrap, _) = control::seal_control_edition(&rumor, &control, &me, Timestamp::from_secs(at)).map_err(|e| e.to_string())?;
1812    if !session.is_valid() {
1813        return Err("account changed before control publish".to_string());
1814    }
1815    transport.publish(&wrap, &community.relays).await?;
1816    // Advance our own floor so a follow-up edit chains from this head and refuse-
1817    // downgrade holds; open our own wrap to recover the self_hash + inner_id.
1818    // Re-check the session AFTER the publish await: a swap mid-publish means the
1819    // pool now points at another account's DB — skipping is safe (the next own
1820    // edit rebuilds the same head from the relay's copy).
1821    if !session.is_valid() {
1822        return Ok(());
1823    }
1824    if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
1825        crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
1826    }
1827    Ok(())
1828}
1829
1830/// Create or edit a Role (vsk 1, CORD-04 §2). `role.role_id` is the coordinate; a
1831/// rename or permission change is a versioned edit of the same id. Gated on the
1832/// reader side by `MANAGE_ROLES` + outrank.
1833pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
1834    let session = SessionGuard::capture();
1835    super::roles::validate_role(role)?;
1836    let content = super::roles::role_content_json(role)?;
1837    let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
1838    publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content, None).await
1839}
1840
1841/// Grant or revoke a member's Roles (vsk 3, CORD-04 §2). Empty `role_ids` is a
1842/// revoke. Gated on the reader side by `MANAGE_ROLES` + outrank of every role + the
1843/// member.
1844pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
1845    let session = SessionGuard::capture();
1846    let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
1847    let content = super::roles::grant_content_json(&grant)?;
1848    let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
1849    publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content, None).await
1850}
1851
1852/// The community's @admin role id: the folded Server-scope ADMIN_ALL role when one
1853/// exists, else (with `create_if_missing`) a DETERMINISTIC mint — the same id on
1854/// every device, so concurrent grants converge as editions of ONE entity instead
1855/// of forking two Admin roles.
1856pub async fn ensure_admin_role<T: Transport + ?Sized>(
1857    transport: &T,
1858    community: &CommunityV2,
1859    view: &AuthorityView,
1860    create_if_missing: bool,
1861) -> Result<Option<String>, String> {
1862    use crate::community::roles::{Permissions, Role, RoleScope};
1863    if let Some(r) = view
1864        .roles
1865        .roles
1866        .iter()
1867        .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_ALL))
1868    {
1869        return Ok(Some(r.role_id.clone()));
1870    }
1871    if !create_if_missing {
1872        return Ok(None);
1873    }
1874    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1875    let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
1876    set_role(transport, community, &Role::admin(role_id.clone())).await?;
1877    Ok(Some(role_id))
1878}
1879
1880/// Grant the @admin role (minting it deterministically when absent), MERGED into
1881/// the member's existing grant — a grant entity replaces whole (CORD-04 §2), so a
1882/// blind push would erase their other roles. Owner-only: the position-1 Admin is
1883/// manageable only by position 0 (an equal never outranks it), and refusing
1884/// before any publish keeps an unauthorized edition of the DETERMINISTIC admin
1885/// entity from advancing this device's own floor onto a head readers reject.
1886pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
1887    // Guard spans the multi-page fetch below: a swap mid-fetch must not let the
1888    // downstream publish's own (post-swap) guard write account A's floor into B.
1889    let session = SessionGuard::capture();
1890    let me = local_keys()?;
1891    if me.public_key() != community.owner()? {
1892        return Err("only the community owner can grant @admin".to_string());
1893    }
1894    let view = fetch_authority(transport, community).await;
1895    if !session.is_valid() {
1896        return Err("account changed during grant".to_string());
1897    }
1898    let member_hex = member.to_hex();
1899    require_grant_head(community, &view, &member_hex)?;
1900    let role_id = ensure_admin_role(transport, community, &view, true)
1901        .await?
1902        .expect("create_if_missing yields an id");
1903    let mut role_ids = view
1904        .roles
1905        .grants
1906        .iter()
1907        .find(|g| g.member == member_hex)
1908        .map(|g| g.role_ids.clone())
1909        .unwrap_or_default();
1910    if role_ids.contains(&role_id) {
1911        return Ok(()); // already admin — don't bump the grant edition for nothing.
1912    }
1913    role_ids.push(role_id);
1914    grant_roles(transport, community, member, role_ids).await
1915}
1916
1917/// Strip the @admin role from the member's grant, preserving their other roles.
1918/// A no-op when they don't hold it. Owner-only, like [`grant_admin`].
1919pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
1920    let session = SessionGuard::capture();
1921    let me = local_keys()?;
1922    if me.public_key() != community.owner()? {
1923        return Err("only the community owner can revoke @admin".to_string());
1924    }
1925    let view = fetch_authority(transport, community).await;
1926    if !session.is_valid() {
1927        return Err("account changed during revoke".to_string());
1928    }
1929    let member_hex = member.to_hex();
1930    require_grant_head(community, &view, &member_hex)?;
1931    let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
1932        return Ok(()); // no admin role exists — nothing to revoke.
1933    };
1934    let mut role_ids = view
1935        .roles
1936        .grants
1937        .iter()
1938        .find(|g| g.member == member_hex)
1939        .map(|g| g.role_ids.clone())
1940        .unwrap_or_default();
1941    let before = role_ids.len();
1942    role_ids.retain(|r| r != &role_id);
1943    if role_ids.len() == before {
1944        return Ok(());
1945    }
1946    grant_roles(transport, community, member, role_ids).await
1947}
1948
1949/// A grant replaces whole — refuse the merge when this member's grant is FLOORED
1950/// locally but no head folded (withheld / evicted): a blind push at that point
1951/// would erase their other roles at a higher version.
1952fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
1953    let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
1954        return Err("malformed member key".to_string());
1955    };
1956    let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
1957    if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
1958        return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
1959    }
1960    Ok(())
1961}
1962
1963/// Replace the Banlist (vsk 4, CORD-04 §4) with `banned` (lowercase-hex npubs), the
1964/// whole list on every edit. Gated on the reader side by `BAN`.
1965pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
1966    let session = SessionGuard::capture();
1967    super::roles::validate_banlist(banned)?;
1968    let content = super::roles::banlist_content_json(banned)?;
1969    let eid = super::derive::banlist_locator(community.id());
1970    publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content, None).await
1971}
1972
1973/// Edit the community metadata (vsk 0, CORD-02 §6). Gated on the reader side by
1974/// `MANAGE_METADATA`.
1975pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
1976    let session = SessionGuard::capture();
1977    control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
1978    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
1979    publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content, None).await
1980}
1981
1982/// Add or edit a channel's metadata (vsk 2, CORD-03 §2). `channel_id` is the
1983/// coordinate. Gated on the reader side by `MANAGE_CHANNELS`.
1984pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
1985    let session = SessionGuard::capture();
1986    let me = local_keys()?;
1987    ensure_channel_manager(community, &me.public_key())?;
1988    // Public → private CONVERSION is a key rotation (CORD-03 §2) this build doesn't
1989    // mint yet — refuse the flag flip rather than publish an edition no reader can
1990    // key (members would keep posting on the root-derived plane, splitting the
1991    // channel). Private → public works (readers heal to the root derivation).
1992    if meta.private {
1993        if let Some(held) = community.channel(channel_id) {
1994            if !held.private {
1995                return Err("converting a public channel to private is not supported yet".to_string());
1996            }
1997        }
1998    }
1999    control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
2000    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
2001    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await
2002}
2003
2004/// The local mirror of the reader's `MANAGE_CHANNELS` fold gate (CORD-03 §2): the
2005/// owner, or a roster-authorized manager who isn't banned. Refusing BEFORE any
2006/// publish keeps an unauthorized device from advancing its own edition floor onto
2007/// a head every reader rejects (wedging its later, legitimately-authorized edits
2008/// behind a rejected chain).
2009fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
2010    let owner = community.owner()?;
2011    if *me == owner {
2012        return Ok(());
2013    }
2014    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2015    let me_hex = me.to_hex();
2016    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
2017        return Err("you are banned from this community".to_string());
2018    }
2019    let roster = crate::db::community::get_community_roles(&cid_hex)?;
2020    if roster.is_authorized(&me_hex, Some(&owner.to_hex()), crate::community::roles::Permissions::MANAGE_CHANNELS) {
2021        Ok(())
2022    } else {
2023        Err("managing channels here needs the MANAGE_CHANNELS permission".to_string())
2024    }
2025}
2026
2027/// Create a new PUBLIC channel (CORD-03 §2): mint a fresh id, publish its metadata
2028/// edition (vsk 2), and add it to the held community. A Public channel derives its Chat
2029/// Plane from the `community_root` (no per-channel key), so other members fold it in on
2030/// their next control follow with nothing to distribute. Returns the new channel id.
2031/// Reader-gated by `MANAGE_CHANNELS`.
2032pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
2033    let session = SessionGuard::capture();
2034    // Serialize with the follow worker: the save below writes the WHOLE community
2035    // row from this caller's struct, so an unserialized concurrent follow adopting
2036    // a rotation would be rolled back to a stale root (a deaf community).
2037    let lock = super::realtime::follow_lock(community.id());
2038    let _guard = lock.lock().await;
2039    let me = local_keys()?;
2040    ensure_channel_manager(community, &me.public_key())?;
2041    let channel_id = ChannelId(super::super::random_32());
2042    let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
2043    control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
2044    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2045    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2046    if !session.is_valid() {
2047        return Err("account changed during channel create".to_string());
2048    }
2049    // Add locally + persist so the creator can post immediately (peers fold it in).
2050    let mut updated = community.clone();
2051    updated.channels.push(ChannelV2 { id: channel_id, name: name.to_string(), private: false, key: None, epoch: updated.root_epoch });
2052    crate::db::community::save_community_v2(&updated)?;
2053    Ok(channel_id)
2054}
2055
2056/// Create a new PRIVATE channel (CORD-03 §2): mint a fresh id + an independent
2057/// random key at channel-epoch 1, deliver the key to every current member over the
2058/// rekey plane (CORD-06 §1), then announce the channel (vsk 2, `private`). Epoch 0
2059/// is the root generation ("the first privatisation is epoch 1"), so the delivery
2060/// commits its continuity to `(0, community_root)` — verifiable by every member and
2061/// bound to THIS community's root. The key ships BEFORE the announcement: an
2062/// aborted attempt leaves only an unannounced crate (invisible), and a retry mints
2063/// a fresh id, so there is no same-coordinate double-mint to fork on. Live public
2064/// links are refreshed so a joiner's bundle carries the key; a member who joins
2065/// through the stale-bundle window keys up at the channel's next rotation.
2066pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
2067    let session = SessionGuard::capture();
2068    // Serialize with the follow worker across the whole fetch→publish→save span
2069    // (the memberlist fetch is seconds long; an unserialized follow adopting a
2070    // rotation meanwhile would be rolled back by the whole-row save below).
2071    let lock = super::realtime::follow_lock(community.id());
2072    let _guard = lock.lock().await;
2073    let me = local_keys()?;
2074    ensure_channel_manager(community, &me.public_key())?;
2075    let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
2076    control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
2077    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2078
2079    let channel_id = ChannelId(super::super::random_32());
2080    let channel_key = super::super::random_32();
2081    let epoch = Epoch(1);
2082
2083    // Recipients: every current member, plus me (multi-device).
2084    let mut recipients = memberlist(transport, community).await?;
2085    if !recipients.iter().any(|p| *p == me.public_key()) {
2086        recipients.push(me.public_key());
2087    }
2088    let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
2089    let mut blobs = Vec::with_capacity(recipients.len());
2090    for r in &recipients {
2091        blobs.push(
2092            rekey::build_blob_local(me.secret_key(), &me.public_key().to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
2093                .map_err(|e| e.to_string())?,
2094        );
2095    }
2096    let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
2097    let at_secs = now_ms() / 1000;
2098    let chunks = rekey::build_rekey_chunks_local(&me, &group, RekeyScope::Channel(channel_id), epoch, Epoch(0), &prev_commit, &blobs, at_secs)
2099        .map_err(|e| e.to_string())?;
2100    if !session.is_valid() {
2101        return Err("account changed during channel create".to_string());
2102    }
2103    for c in &chunks {
2104        transport.publish_durable(c, &community.relays).await?;
2105    }
2106    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2107    if !session.is_valid() {
2108        return Err("account changed during channel create".to_string());
2109    }
2110    // A leave/delete raced the create: saving would resurrect the community row.
2111    if crate::db::community::community_protocol(community.id())?.is_none() {
2112        return Err("community removed during channel create".to_string());
2113    }
2114    let mut updated = community.clone();
2115    updated.channels.push(ChannelV2 { id: channel_id, name: name.to_string(), private: true, key: Some(channel_key), epoch });
2116    crate::db::community::save_community_v2(&updated)?;
2117    // Archive the epoch-1 key so this channel's history stays readable across its
2118    // future rotations (CORD-03 §3).
2119    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2120    crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
2121    // Future joiners are handed the key in their (refreshed) bundle, CORD-05 §1.
2122    let _ = refresh_public_links(transport, &updated).await;
2123    Ok(channel_id)
2124}
2125
2126/// Tombstone a channel (CORD-03 §2, `deleted: true`) + drop it locally. Reader-gated by
2127/// `MANAGE_CHANNELS`; the coordinate stays folded as a grave so peers hide it.
2128pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
2129    let session = SessionGuard::capture();
2130    // Whole-row save below — serialize with the follow worker (see create_*_channel).
2131    let lock = super::realtime::follow_lock(community.id());
2132    let _guard = lock.lock().await;
2133    let me = local_keys()?;
2134    ensure_channel_manager(community, &me.public_key())?;
2135    let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: Some(true), custom: None, extra: Default::default() };
2136    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2137    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2138    if !session.is_valid() {
2139        return Err("account changed during channel delete".to_string());
2140    }
2141    let mut updated = community.clone();
2142    updated.channels.retain(|c| c.id.0 != channel_id.0);
2143    crate::db::community::save_community_v2(&updated)?;
2144    Ok(())
2145}
2146
2147// ── Live control-follow (CORD-02 §6 / CORD-03 §2) ────────────────────────────
2148
2149/// Re-fold this community's Control Plane and apply the current metadata +
2150/// **public** channel set to the held community, persisting any change. Called
2151/// when a control-plane wrap arrives in realtime (a rename, a new channel, an
2152/// edited description) so a long-running bot tracks the community mid-session
2153/// instead of freezing at its join-time view.
2154///
2155/// **Authority (CORD-04 §5):** the roster (roles/grants/banlist) folds first into
2156/// the owner-seeded authorized set ([`fold_authority`]), then each metadata/channel
2157/// edition is eligible only if its signer CURRENTLY holds the entity's management
2158/// bit (`MANAGE_METADATA`/`MANAGE_CHANNELS`) — so an authorized admin's edits fold,
2159/// a demoted one's drop. The owner is supreme, proven by the self-certifying
2160/// community_id (no network trust).
2161///
2162/// **Private channels are skipped here:** a Private channel's Chat-Plane key is
2163/// delivered over the rekey plane (or an invite bundle), never derivable from a
2164/// control edition alone. A new Private channel therefore surfaces only once
2165/// [`follow_rekeys`] delivers its key. Public channels derive from the
2166/// community_root, so they fold in directly.
2167///
2168/// Returns the updated community iff something changed (so the caller can skip a
2169/// redundant re-subscribe + refresh notification).
2170pub async fn follow_control<T: Transport + ?Sized>(
2171    transport: &T,
2172    community: &CommunityV2,
2173    session: &SessionGuard,
2174) -> Result<Option<CommunityV2>, String> {
2175    community.owner()?; // fail fast if the community is somehow unproven.
2176    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
2177    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2178
2179    // Per-entity refuse-downgrade floors for the CURRENT epoch only. A head recorded
2180    // under a prior epoch is excluded, so that entity auto-bootstraps after a
2181    // Refounding (Armada accepts a compacted head across a dangling prev — matched).
2182    // A read error FAILS CLOSED: an empty map would silently re-open the rollback
2183    // window the floor exists to shut.
2184    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2185        .into_iter()
2186        .filter(|(_, f)| f.0 == community.root_epoch.0)
2187        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2188        .collect();
2189
2190    // Newest window first; page OLDER only while a tracking entity is gapped (its
2191    // floor link evicted from the window — H1/M8 refetch), bounded like the join
2192    // verifier. A withholding relay still converges to fail-closed after the cap.
2193    let mut editions: Vec<ParsedEdition> = Vec::new();
2194    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2195    let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
2196    let mut oldest: Option<u64> = None;
2197    let mut until: Option<u64> = None;
2198    let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false };
2199    let mut authority = AuthoritySet::owner_only();
2200    for _ in 0..FOLLOW_MAX_PAGES {
2201        let query = Query {
2202            kinds: vec![stream::KIND_WRAP],
2203            authors: vec![control.pk_hex()],
2204            until,
2205            limit: Some(FOLLOW_PAGE),
2206            ..Default::default()
2207        };
2208        let wraps = transport.fetch(&query, &community.relays).await?;
2209        // The `until` cursor is INCLUSIVE (a `-1` step can skip same-second siblings
2210        // at a page boundary); the wrap-id dedup makes re-served boundary events
2211        // free, and a page with nothing new means the relay is exhausted.
2212        let mut fresh = 0usize;
2213        for w in &wraps {
2214            if !seen_wraps.insert(w.id) {
2215                continue;
2216            }
2217            fresh += 1;
2218            let at = w.created_at.as_secs();
2219            if oldest.is_none_or(|o| at < o) {
2220                oldest = Some(at);
2221            }
2222            // Open + seal-verify every edition; authority is resolved by the roster
2223            // fold (CORD-04 §5), not by a signer filter here — an admin's edits fold.
2224            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
2225                if seen.insert(ed.inner_id) {
2226                    editions.push(ed);
2227                }
2228            }
2229        }
2230        // Roster first (roles/grants/banlist → authorized set), then the authority-
2231        // gated metadata/channel fold over the same edition set.
2232        authority = fold_authority(community, &editions, &floors);
2233        fold = apply_control_fold(community, &editions, &floors, &authority);
2234        if !(fold.gapped || authority.gapped) || fresh == 0 {
2235            break;
2236        }
2237        until = oldest;
2238    }
2239
2240    // The fetches straddled awaits; a swap since the guard was captured must not
2241    // write account A's control state into B.
2242    if !session.is_valid() {
2243        return Err("account changed during control follow".to_string());
2244    }
2245    // A leave/delete raced this follow: writing now would resurrect the community
2246    // row and orphan floor rows past delete_community's wipe.
2247    if crate::db::community::community_protocol(community.id())?.is_none() {
2248        return Ok(None);
2249    }
2250    // Persist advanced floors BEFORE the state save (a failed floor write must not
2251    // let saved state outrun its floor), stamping the epoch this fold ran under —
2252    // not the row's write-time value, which a concurrent re-founding can bump. Both
2253    // the metadata/channel heads and the roster/banlist heads advance their floors;
2254    // run the advance (v+1) and same-version convergence (fork tiebreak) paths.
2255    for h in fold.heads.iter().chain(authority.heads.iter()) {
2256        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)?;
2257        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)?;
2258    }
2259    // Persist the authorized banlist content (retained/withholding folds carry None,
2260    // so the stored banlist is left intact — an anti-roster never silently un-bans).
2261    if let Some((banned, version)) = &authority.banlist_persist {
2262        crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
2263    }
2264    // Persist the authorized roster so capabilities/roles stay sync LOCAL reads
2265    // (v1 parity: the passive follow folds, reads never fetch). Guarded like v1's
2266    // fetch path: only an aggregate built from roster editions at least as new as
2267    // the stored one may replace it — a withholding relay serving NO roster
2268    // editions folds an empty-but-ungapped aggregate (absence raises no gap flag),
2269    // and that must RETAIN the stored roster, never wipe standing.
2270    let newest_roster_at: i64 = editions
2271        .iter()
2272        .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
2273        .map(|e| e.created_at as i64)
2274        .max()
2275        .unwrap_or(0);
2276    // Completeness gate: the `gapped` flag only covers entities present in the window.
2277    // A role/grant floored on this device but with ZERO editions fetched (aged out of
2278    // the paging reach) folds absent yet raises no gap — persisting would silently drop
2279    // it. So if any CURRENTLY-STORED entity is floored but folded no head this round,
2280    // RETAIN. A real revoke still folds a head (see select_authorized), so it persists.
2281    let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2282    let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
2283    let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
2284        && stored.grants.iter().all(|g| {
2285            crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
2286                let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
2287                !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
2288            })
2289        });
2290    if !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
2291        crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
2292    }
2293    match fold.updated {
2294        Some(u) => {
2295            crate::db::community::save_community_v2(&u)?;
2296            Ok(Some(u))
2297        }
2298        None => Ok(None),
2299    }
2300}
2301
2302/// Control-follow paging bounds: enough depth to re-anchor a long-offline floor
2303/// (H1/M8 refetch) without letting a flooding relay stall the follow queue.
2304const FOLLOW_MAX_PAGES: usize = 4;
2305const FOLLOW_PAGE: usize = 500;
2306
2307/// A folded control head to persist as the per-entity refuse-downgrade floor.
2308#[derive(Clone)]
2309struct FoldedHead {
2310    entity_hex: String,
2311    version: u64,
2312    self_hash: [u8; 32],
2313    inner_id: [u8; 32],
2314}
2315
2316/// The outcome of a floor-aware control fold: the updated community (if content
2317/// changed), the heads to persist as the new floor (returned even when content is
2318/// unchanged, so the floor still seeds/advances), and whether any TRACKING entity
2319/// hit an unresolvable gap — the caller's signal to page older history and re-fold
2320/// (CORD-04 H1/M8's refetch).
2321struct ControlFold {
2322    updated: Option<CommunityV2>,
2323    heads: Vec<FoldedHead>,
2324    gapped: bool,
2325}
2326
2327/// Per-entity floor: `(version, self_hash, inner_id)` of the committed head.
2328type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
2329
2330/// Fold owner-authored control editions into an updated community using the
2331/// PERSISTED per-entity version floor (refuse-downgrade). Per entity, fold with
2332/// [`version::fold`]`(floor, floor_hash)`:
2333///   - ANCHORED: adopt the chain-verified head. A `gap` ABOVE it (withheld middles)
2334///     doesn't block the verified prefix — refuse-downgrade holds for everything
2335///     applied — but flags `gapped` so the caller pages for the rest.
2336///   - UNANCHORED under a held floor: one legitimate cause is a same-version owner
2337///     fork AT the floor whose deterministic winner (lower inner id; a NULL held id
2338///     is always replaceable, mirroring v1's `decide()`) isn't our held edition —
2339///     the floor CONVERGES to the winner and the chain re-anchors on it, so every
2340///     client lands on the same head where a hash-strict floor would wedge forever.
2341///     Anything else is withholding → fail closed + `gapped`.
2342///   - BOOTSTRAPPING (`floor == 0` — a fresh joiner, or a fresh epoch after a
2343///     Refounding, since the caller epoch-filters the floor) takes the highest
2344///     signed head (author already owner-filtered).
2345/// This matches CORD-04 §1 and mirrors v1's `fold_roster`. Epoch-filtering makes a
2346/// compaction at a new epoch auto-bootstrap, converging with Armada's acceptance of
2347/// a compacted head across a dangling `prev` (Armada doesn't persist a floor, so a
2348/// Vector floor only makes Vector STRICTER locally — no wire change, honest-case
2349/// convergence preserved).
2350fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
2351    use crate::community::roles::Permissions;
2352    use std::collections::BTreeMap;
2353
2354    let owner_hex = community.owner().ok().map(|o| o.to_hex());
2355
2356    let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
2357    for e in editions {
2358        groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
2359    }
2360
2361    let mut out = community.clone();
2362    let mut changed = false;
2363    let mut heads = Vec::new();
2364    let mut gapped = false;
2365    for ((vsk_code, eid), group) in &groups {
2366        // This fold applies exactly two entities: community metadata (eid ==
2367        // community_id) and channel metadata. A vsk-2 whose eid equals the community
2368        // id is excluded — the floor row keys on the entity alone, so it would share
2369        // (and corrupt) the metadata chain's floor.
2370        let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
2371        let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
2372        if !is_meta && !is_channel {
2373            continue;
2374        }
2375        // Authority gate (CORD-04 §5): only editions whose author CURRENTLY holds the
2376        // entity's management bit are eligible. Pre-filtering before the fold means a
2377        // demoted admin's (possibly higher-version) edition can't be the head; the
2378        // highest AUTHORIZED head wins. The owner is supreme.
2379        let required = if is_meta { Permissions::MANAGE_METADATA } else { Permissions::MANAGE_CHANNELS };
2380        let authed: Vec<&ParsedEdition> = group
2381            .iter()
2382            .copied()
2383            .filter(|e| {
2384                let author = e.author.to_hex();
2385                // A banned npub's edits are dropped (CORD-04 §4), even if they still
2386                // held a bit via a not-yet-stripped grant.
2387                !authority.banned.contains(&author) && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
2388            })
2389            .collect();
2390        if authed.is_empty() {
2391            continue;
2392        }
2393        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
2394        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
2395        let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
2396        gapped |= entity_gapped;
2397        let Some(hi) = hi else { continue };
2398
2399        let head = authed[hi];
2400        heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
2401        if is_meta {
2402            if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
2403                changed |= apply_community_metadata(&mut out, meta);
2404            }
2405        } else if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
2406            // vsk-2 carries no community binding (shared v1 grammar); a same-owner
2407            // cross-community replay can inject a phantom PUBLIC channel (bounded:
2408            // root-scoped key, eids don't collide). Binding is a deferred wire change.
2409            changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
2410        }
2411    }
2412    ControlFold { updated: changed.then_some(out), heads, gapped }
2413}
2414
2415/// Fold one entity's editions against its persisted floor into a head index (into the
2416/// input slice) plus whether a TRACKING gap was hit (the caller pages older history).
2417/// Encapsulates the W2 refuse-downgrade policy: bootstrap at floor 0 (highest signed
2418/// head, what Armada shows across a compaction's dangling prev); adopt the chain-
2419/// anchored head, paging on an upper gap; converge a same-version fork at the floor to
2420/// the lower-inner-id winner; and fail closed otherwise.
2421fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
2422    let floor_v = floor.map(|f| f.0).unwrap_or(0);
2423    if floor_v == 0 {
2424        return (version::bootstrap_head(fold_eds, 0), false);
2425    }
2426    let floor_hash = floor.map(|f| &f.1);
2427    let held_inner = floor.and_then(|f| f.2);
2428    let result = version::fold(fold_eds, floor_v, floor_hash);
2429    if result.anchored {
2430        return (result.head, result.gap); // verified prefix; page any upper gap.
2431    }
2432    if result.head.is_none() && !result.gap {
2433        return (None, false); // everything below floor — a stale relay, no paging.
2434    }
2435    // Unanchored under a held floor: converge a same-version fork at the floor to its
2436    // deterministic winner (lower inner id; a NULL held id is always replaceable),
2437    // else fail closed.
2438    let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
2439    let win_hash = match fork {
2440        Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
2441        _ => return (None, true), // detached from our committed head → withholding.
2442    };
2443    let re = version::fold(fold_eds, floor_v, Some(&win_hash));
2444    if !re.anchored {
2445        return (None, true);
2446    }
2447    (re.head, re.gap)
2448}
2449
2450/// The folded, delegation-AUTHORIZED control-plane authority (CORD-04): the roster
2451/// (roles + grants, owner-seeded fixpoint), the enforced banlist, and the
2452/// role/grant/banlist heads to persist as refuse-downgrade floors. The owner is
2453/// recomputed from the self-certifying community_id at each use.
2454struct AuthoritySet {
2455    roles: crate::community::roles::CommunityRoles,
2456    banned: std::collections::BTreeSet<String>,
2457    heads: Vec<FoldedHead>,
2458    gapped: bool,
2459    /// The authorized banlist `(content, version)` to persist when an authorized head
2460    /// advanced the floor. `None` when the banlist was retained (no new authorized
2461    /// head) or is empty — the caller then leaves the stored banlist untouched.
2462    banlist_persist: Option<(Vec<String>, u64)>,
2463}
2464
2465impl AuthoritySet {
2466    /// Bootstrap authority for a community with no roster editions folded yet: only
2467    /// the owner is authorized (supreme), nobody banned.
2468    fn owner_only() -> Self {
2469        AuthoritySet { roles: Default::default(), banned: Default::default(), heads: vec![], gapped: false, banlist_persist: None }
2470    }
2471}
2472
2473/// Fold the roster/banlist entities (vsk 1/3/4) from the control editions into the
2474/// delegation-AUTHORIZED roster + enforced banlist (CORD-04 §2-§5). Each entity binds
2475/// to its coordinate (role at role_id, grant at grant_locator(cid, member), banlist at
2476/// banlist_locator(cid)); a content whose coordinate doesn't match is dropped. Roles
2477/// cap at the 100 lowest role_ids, a member at 64 roles, the banlist at 500. The
2478/// banlist is enforced only if its head's signer held BAN in the authorized roster.
2479fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
2480    use crate::community::roles::Permissions;
2481    use std::collections::BTreeMap;
2482
2483    let cid = community.id();
2484    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2485    let owner = community.owner().ok();
2486    let owner_hex = owner.map(|o| o.to_hex());
2487    let banlist_eid = super::derive::banlist_locator(cid);
2488    let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
2489
2490    let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
2491    for e in editions {
2492        if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
2493            groups.entry(e.entity_id).or_default().push(e);
2494        }
2495    }
2496
2497    // Per-entity CANDIDATE lists — every ≥floor edition of a role/grant, highest
2498    // version first (lowest inner-id as the deterministic tiebreak). CORD-04 §1: an
2499    // edition whose signer isn't authorized is SIMPLY DROPPED and the fold continues
2500    // to the next candidate, so a forged higher-version edition can't suppress the
2501    // authorized head beneath it (the author-blind collapse-to-one-head it replaces
2502    // let any member vanish a role or a member's grant). `gapped` (drives older-
2503    // paging) stays fold_head's per-entity flag.
2504    let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
2505    let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
2506    let mut gapped = false;
2507
2508    for (eid, group) in &groups {
2509        // The banlist is folded author-aware AFTER the roster is known (below).
2510        if *eid == banlist_eid {
2511            continue;
2512        }
2513        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
2514        let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
2515        let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
2516        gapped |= entity_gapped;
2517        let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
2518
2519        for p in group {
2520            // Refuse-downgrade: never consider an edition below the persisted floor.
2521            if p.version < floor_v {
2522                continue;
2523            }
2524            let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
2525            match p.vsk.as_str() {
2526                vsk::ROLE => {
2527                    // Bind: the content's role_id IS the coordinate; position 0 is the owner's.
2528                    if let Some(role) = super::roles::parse_role_content(&p.content) {
2529                        if role.role_id == entity_hex && role.position != 0 {
2530                            role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head });
2531                        }
2532                    }
2533                }
2534                vsk::GRANT => {
2535                    if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
2536                        if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
2537                            if super::derive::grant_locator(cid, &member) == *eid {
2538                                grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
2539                                grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head });
2540                            }
2541                        }
2542                    }
2543                }
2544                _ => {}
2545            }
2546        }
2547    }
2548    for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
2549        cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
2550    }
2551
2552    let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2553    // Preliminary roster (bans not yet applied) — the authority view the banlist head
2554    // is judged against.
2555    let (prelim, _) = select_authorized(&role_cands, &grant_cands, owner_hex.as_deref(), &empty);
2556
2557    // Banlist (CORD-04 §4), folded AUTHORITY-aware so its two anti-roster hazards are
2558    // both closed:
2559    //   - head selection: the head is the highest version whose author CURRENTLY holds
2560    //     BAN — an unauthorized higher-version edition can't erase existing bans
2561    //     (fail-open), and the floor never advances to one;
2562    //   - per-target: each entry is kept only if the author STRICTLY OUTRANKS that
2563    //     target (`can_act_on_member` — an admin can't ban a peer/superior, and the
2564    //     owner is unbannable);
2565    //   - withholding: when no authorized head is served, the persisted banlist is
2566    //     RETAINED (an anti-roster must not un-ban on a relay withholding the ban).
2567    let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2568    // An ALREADY-banned npub can't author the banlist (a banned member vanishes, §4), or
2569    // a BAN-holder whose grant-strip hasn't yet folded could publish a list omitting their
2570    // OWN ban to un-ban themselves (removals aren't outrank-checked). Exclude them from
2571    // head eligibility, not just from the roster.
2572    let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
2573    let banlist_authored: Vec<&ParsedEdition> = groups
2574        .get(&banlist_eid)
2575        .map(|g| {
2576            g.iter()
2577                .copied()
2578                .filter(|e| {
2579                    let ah = e.author.to_hex();
2580                    !banned_authors.contains(ah.as_str()) && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
2581                })
2582                .collect()
2583        })
2584        .unwrap_or_default();
2585    let mut banlist_persist: Option<(Vec<String>, u64)> = None;
2586    let mut banlist_head: Option<FoldedHead> = None;
2587    let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
2588        persisted_banned.into_iter().collect()
2589    } else {
2590        let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
2591        let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
2592        gapped |= g;
2593        match hi {
2594            Some(hi) => {
2595                let head = banlist_authored[hi];
2596                let ah = head.author.to_hex();
2597                let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
2598                    .unwrap_or_default()
2599                    .into_iter()
2600                    .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
2601                    .take(super::roles::MAX_BANLIST)
2602                    .collect();
2603                banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
2604                banlist_persist = Some((list.clone(), head.version));
2605                list.into_iter().collect()
2606            }
2607            None => persisted_banned.into_iter().collect(),
2608        }
2609    };
2610
2611    // Final roster (CORD-04 §4: a banned npub vanishes — every edition it authored is
2612    // dropped, and a grant TO a banned member carries no rank). Re-run selection with
2613    // the banned set excluded so a banned admin loses authority.
2614    let (mut authorized, mut heads) = select_authorized(&role_cands, &grant_cands, owner_hex.as_deref(), &banned);
2615    if let Some(bh) = banlist_head {
2616        heads.push(bh);
2617    }
2618
2619    // Cap the AUTHORIZED community at the 100 lowest role_ids — applied AFTER
2620    // authorization, so an attacker's unauthorized roles can't consume cap slots and
2621    // evict a legitimate one (the pre-authorize cap they replace let 100 forged low-id
2622    // roles empty the roster).
2623    if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
2624        authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
2625        authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
2626        let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
2627        authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
2628        authorized.grants.retain(|g| !g.role_ids.is_empty());
2629    }
2630
2631    AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist }
2632}
2633
2634/// One candidate edition of a role/grant entity — the pool [`select_authorized`]
2635/// draws the highest AUTHORIZED head from (exactly one of `role`/`grant` is set).
2636struct AuthorityCand {
2637    role: Option<crate::community::roles::Role>,
2638    grant: Option<crate::community::roles::MemberGrant>,
2639    author: PublicKey,
2640    head: FoldedHead,
2641}
2642
2643/// The owner-seeded delegation fixpoint (CORD-04 §1/§2), author-AWARE: per entity it
2644/// takes the highest-version candidate whose author is authorized to author it under
2645/// the roster resolved SO FAR, dropping unauthorized higher versions rather than
2646/// vanishing the entity. Authority resolves outward from the owner (proven by
2647/// `community_id`, never a Role), and the strict-outrank rule (no edition at/above its
2648/// signer's own position) keeps the fixpoint monotone, so it converges. Returns the
2649/// authorized roster plus the per-entity heads of the SELECTED editions (the floor
2650/// advances only to authorized heads — an unauthorized forgery never poisons it).
2651fn select_authorized(
2652    role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
2653    grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
2654    owner_hex: Option<&str>,
2655    excluded: &std::collections::BTreeSet<String>,
2656) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
2657    use crate::community::roles::{CommunityRoles, Permissions};
2658    let mut accepted = CommunityRoles::default();
2659    let mut heads: Vec<FoldedHead> = Vec::new();
2660    // Jacobi iteration: authority propagates one delegation level per round, so a
2661    // generous multiple of the entity count is an ample bound. Non-convergence (never
2662    // seen for an owner-rooted chain) falls through fail-safe: only authorized editions
2663    // are ever selected.
2664    let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
2665    for _ in 0..bound {
2666        let mut next = CommunityRoles::default();
2667        let mut next_heads: Vec<FoldedHead> = Vec::new();
2668
2669        for cands in role_cands.values() {
2670            for c in cands {
2671                let Some(role) = &c.role else { continue };
2672                let ah = c.author.to_hex();
2673                if excluded.contains(&ah) {
2674                    continue;
2675                }
2676                if role.position != 0 && accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
2677                    next.roles.push(role.clone());
2678                    next_heads.push(c.head.clone());
2679                    break; // highest authorized candidate for this entity
2680                }
2681            }
2682        }
2683        for cands in grant_cands.values() {
2684            for c in cands {
2685                let Some(grant) = &c.grant else { continue };
2686                let ah = c.author.to_hex();
2687                if excluded.contains(&ah) || excluded.contains(&grant.member) {
2688                    continue;
2689                }
2690                // The granter must outrank every granted role (resolved against the
2691                // accepted roster) AND the member — the escalation defense (CORD-04 §2).
2692                let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
2693                let Some(positions) = positions else { continue };
2694                if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
2695                    && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
2696                {
2697                    // Record the head even for an EMPTY grant (a revoke is a real chain
2698                    // advance a completeness check must see), but don't carry the husk
2699                    // into the roster.
2700                    next_heads.push(c.head.clone());
2701                    if !grant.role_ids.is_empty() {
2702                        next.grants.push(grant.clone());
2703                    }
2704                    break;
2705                }
2706            }
2707        }
2708
2709        let converged = next.roles == accepted.roles && next.grants == accepted.grants;
2710        accepted = next;
2711        heads = next_heads;
2712        if converged {
2713            break;
2714        }
2715    }
2716    (accepted, heads)
2717}
2718
2719/// Apply a folded community-metadata head. Relays only overwrite when the edition
2720/// carries a non-empty list (a metadata edition that omits relays must not blank
2721/// the working set). Returns whether anything changed.
2722fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
2723    let mut changed = false;
2724    if out.name != meta.name {
2725        out.name = meta.name;
2726        changed = true;
2727    }
2728    if out.description != meta.description {
2729        out.description = meta.description;
2730        changed = true;
2731    }
2732    if !meta.relays.is_empty() && out.relays != meta.relays {
2733        out.relays = meta.relays;
2734        changed = true;
2735    }
2736    changed
2737}
2738
2739/// Apply a folded channel-metadata head: delete removes the channel, a rename
2740/// updates an existing one, a brand-new PUBLIC channel is added, and a brand-new
2741/// PRIVATE one is recorded KEYLESS (unreadable until its key arrives over the
2742/// rekey plane or a fresh bundle). Returns whether anything changed.
2743fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
2744    let deleted = meta.deleted.unwrap_or(false);
2745    if deleted {
2746        let before = out.channels.len();
2747        out.channels.retain(|c| c.id.0 != id.0);
2748        return out.channels.len() != before;
2749    }
2750    match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
2751        Some(existing) => {
2752            let mut changed = false;
2753            if existing.name != meta.name {
2754                existing.name = meta.name;
2755                changed = true;
2756            }
2757            // The owner's edition authoritatively declares visibility. A channel the
2758            // owner marks PUBLIC must derive from the root (key = None) — this heals a
2759            // bundle-time misclassification where an attacker set a public channel's
2760            // grant key to their own, silently addressing it at a plane only they read.
2761            // Public → private CONVERSION is DEFERRED: the flip is IGNORED here (the
2762            // record stays public) until the convert flow (key mint + cursor rebase
2763            // to the conversion's channel epoch) lands — the send side refuses to
2764            // publish one, and a foreign client's conversion won't move us.
2765            if !meta.private && (existing.private || existing.key.is_some()) {
2766                existing.private = false;
2767                existing.key = None;
2768                changed = true;
2769            }
2770            changed
2771        }
2772        None if !meta.private => {
2773            // A public channel derives its Chat Plane from the community_root at the
2774            // current root epoch (key = None); its stored epoch mirrors the root.
2775            out.channels.push(ChannelV2 {
2776                id,
2777                name: meta.name,
2778                private: false,
2779                key: None,
2780                epoch: out.root_epoch,
2781            });
2782            true
2783        }
2784        None => {
2785            // A brand-new PRIVATE channel: record it KEYLESS at epoch 0 (the root
2786            // generation — CORD-03 §2 numbers the first private key epoch 1). The
2787            // epoch then doubles as [`follow_rekeys`]' scan cursor. Until a rotation
2788            // delivers a key, every read/send/subscribe path skips the channel; the
2789            // root-fallback in `channel_secret` is never taken for it.
2790            out.channels.push(ChannelV2 { id, name: meta.name, private: true, key: None, epoch: Epoch(0) });
2791            true
2792        }
2793    }
2794}
2795
2796// ── Live rekey-follow (CORD-06 §2/§3) ────────────────────────────────────────
2797
2798/// The outcome of a rekey-follow pass.
2799pub struct RekeyFollow {
2800    /// The community after adopting every rotation it could catch up on, or `None`
2801    /// if nothing advanced.
2802    pub updated: Option<CommunityV2>,
2803    /// A base rotation removed us — the caller tears the local hold down (the
2804    /// updated community is not persisted in that case).
2805    pub self_removed: bool,
2806    /// An owner tombstone sits on the dissolved plane (CORD-02 §9) — the local
2807    /// flag is already set; the caller surfaces the death and stops following.
2808    pub dissolved: bool,
2809}
2810
2811/// The most archived base roots a channel-rekey lookup fans across per step. A
2812/// standalone rekey rides the minter's then-current root and a removal's rides the
2813/// PRIOR root (CORD-06 §3), so a follower whose base already advanced must look
2814/// back. A channel stranded DEEPER than this (its next-epoch crate addressed under
2815/// an older root than the fan reaches) only heals via a fresh invite bundle — the
2816/// walk is strictly sequential, so a later rotation can't be reached either.
2817const MAX_ADDRESSING_ROOTS: usize = 8;
2818
2819/// Follow rekeys for a held community: advance the base (root) epoch and each
2820/// Private channel's epoch as far as authorized rotations allow, adopting the
2821/// fresh key we're still a recipient of at each step and dropping a scope we've
2822/// been removed from. Persists the result. Called when a rekey wrap arrives in
2823/// realtime so a long-running bot keeps decrypting after a rotation instead of
2824/// going silent.
2825///
2826/// **Authority (CORD-06 §Authority):** a BASE rotation is honored from the owner
2827/// only — the deliberate mirror of the owner-only Refounding send (a non-owner's
2828/// ban silences + strips; the read-cut is the owner's). A CHANNEL rotation is
2829/// honored from the owner or a `MANAGE_CHANNELS` holder under the PERSISTED
2830/// roster (folded + persisted by `follow_control`), minus the banlist — so an
2831/// admin-created private channel keys up on every member.
2832///
2833/// **Addressing fans across held base roots:** each channel step queries its
2834/// next-epoch rekey address under the current root AND the archived prior roots,
2835/// so a base adopt landing before a Refounding's prior-root-addressed channel
2836/// rekeys (or before a creation delivery minted under an older root) can't
2837/// strand the channel.
2838///
2839/// **Continuity + fork resolution are spec-strict:** a rotation must extend the
2840/// exact `(epoch, key)` I hold, one epoch at a time; a same-epoch fork resolves
2841/// by the lexicographically lowest new key ([`rekey::lowest_key_winner`]), so
2842/// every follower converges. An incomplete rotation (a missing chunk) never
2843/// concludes removal — it just waits. A KEYLESS channel (announced by vsk-2, key
2844/// not yet delivered) holds no chain, so continuity is vacuous for it (CORD-06
2845/// §2: "a convergence check, not a secrecy mechanism") — authority is its
2846/// boundary; its epoch is the scan cursor, advancing past complete rotations
2847/// that exclude us so the walk converges on the channel's current epoch.
2848pub async fn follow_rekeys<T: Transport + ?Sized>(
2849    transport: &T,
2850    community: &CommunityV2,
2851    session: &SessionGuard,
2852) -> Result<RekeyFollow, String> {
2853    // Death wins every race (CORD-02 §9): a dissolved community honors no epoch advance
2854    // past its tombstone — don't adopt a rotation into a grave.
2855    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2856    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2857        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
2858    }
2859    // An offline member must also LEARN of a death: the tombstone rides its own
2860    // public plane, which the live sub watches but no catch-up fetch touched —
2861    // without this, a member who slept through a dissolution follows (and posts
2862    // into) a grave forever. Fail-open on transport failure: availability is
2863    // never death.
2864    if is_dissolved(transport, community).await {
2865        if session.is_valid() {
2866            let _ = crate::db::community::set_community_dissolved(&cid_hex);
2867        }
2868        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
2869    }
2870    let me = local_keys()?;
2871    let my_xonly = me.public_key().to_bytes();
2872    let owner = community.owner()?;
2873    let owner_hex = owner.to_hex();
2874    let mut cur = community.clone();
2875    let mut changed = false;
2876
2877    // The channel-rotator gates. Loaded once per follow: a roster change lands via
2878    // follow_control (which the worker runs right after this), so at worst an
2879    // admin's rotation adopts one pass late — never early.
2880    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2881    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2882    let me_hex = me.public_key().to_hex();
2883    let channel_rotator_ok = |rotator: &PublicKey| -> bool {
2884        if *rotator == owner {
2885            return true;
2886        }
2887        let rh = rotator.to_hex();
2888        !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
2889    };
2890    // Concluding MY removal takes more than the bit: the rotator must strictly
2891    // outrank ME (CORD-06 §Authority — "the Rotator must strictly outrank every
2892    // removed target"), so an equal-rank admin can never silently evict a peer
2893    // (or the owner) by minting a complete rotation that skips their blob.
2894    let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
2895        if *rotator == owner {
2896            return true;
2897        }
2898        let rh = rotator.to_hex();
2899        !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
2900    };
2901    let base_rotator_ok = |rotator: &PublicKey| -> bool { *rotator == owner };
2902
2903    // Bound the catch-up: each real step consumes a valid authorized rotation, so a
2904    // finite chain terminates naturally; the cap defends against a relay feeding a
2905    // pathological set.
2906    const MAX_STEPS: usize = 128;
2907    for _ in 0..MAX_STEPS {
2908        let mut advanced = false;
2909
2910        // The roots a channel rekey may be addressed under, freshest first: the
2911        // current root plus the archived priors (re-read each pass — a base adopt
2912        // below changes the head, and its predecessor is already archived).
2913        let mut addressing_roots: Vec<[u8; 32]> = vec![cur.community_root];
2914        let mut archived = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
2915        archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
2916        for (_, r) in archived {
2917            if !addressing_roots.contains(&r) {
2918                addressing_roots.push(r);
2919            }
2920        }
2921        addressing_roots.truncate(MAX_ADDRESSING_ROOTS);
2922
2923        // Private channels first: a removal-forced channel rekey rides the PRIOR
2924        // root (CORD-06 D2), so read channels before a base adopt moves it.
2925        let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
2926        for cid in channel_ids {
2927            let (held_key, held_epoch) = match cur.channel(&cid) {
2928                Some(ch) => (ch.key, ch.epoch),
2929                None => continue,
2930            };
2931            let next = Epoch(held_epoch.0.saturating_add(1));
2932            let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
2933            for root in &addressing_roots {
2934                let group = channel_rekey_group_key(root, &cid, next);
2935                let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
2936                if chunks.is_empty() {
2937                    continue;
2938                }
2939                batches.push((chunks, held_key.map(|k| (held_epoch, k))));
2940            }
2941            // Keyless-adopt residual (documented, deferred hardening): a malicious
2942            // AUTHORIZED admin can fork a keyless member onto an orphan low-key
2943            // rotation nothing extends (keyed members' continuity filters it out).
2944            // Recoverable via a fresh bundle; an insider with MANAGE_CHANNELS can
2945            // exclude the member outright anyway, so the marginal harm is the wedge
2946            // outliving their demotion.
2947            match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, me.secret_key(), &my_xonly, next) {
2948                Advance::Adopt { new_key } => {
2949                    if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
2950                        ch.key = Some(new_key);
2951                        ch.epoch = next;
2952                    }
2953                    // The adopter's own multi-epoch archive (the minter archived at
2954                    // mint) — this channel's history stays readable across rotations.
2955                    // fetch_channel compensates for the CURRENT epoch, so a failed
2956                    // archive only bites after the NEXT rotation — surface it.
2957                    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) {
2958                        crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
2959                    }
2960                    advanced = true;
2961                    changed = true;
2962                }
2963                Advance::Removed => {
2964                    match held_key {
2965                        // A complete rotation dropped my blob — cut from the channel.
2966                        Some(_) => {
2967                            cur.channels.retain(|c| c.id.0 != cid.0);
2968                        }
2969                        // Keyless scan: this epoch's rotation completed without me.
2970                        // Advance the cursor so the walk converges on the channel's
2971                        // CURRENT epoch — my entry point is its next rotation (whose
2972                        // recipients are the members at that time) or a fresh bundle.
2973                        None => {
2974                            if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
2975                                ch.epoch = next;
2976                            }
2977                        }
2978                    }
2979                    advanced = true;
2980                    changed = true;
2981                }
2982                Advance::Stay => {}
2983            }
2984        }
2985
2986        // Base rotation (Refounding): advances the root + root_epoch, re-addressing
2987        // every public channel, the guestbook, and the control plane by derivation
2988        // (refresh_subscription recomputes the author-set from the new root).
2989        {
2990            let held_epoch = cur.root_epoch;
2991            let held_key = cur.community_root;
2992            let next = Epoch(held_epoch.0.saturating_add(1));
2993            let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
2994            let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
2995            let batches = vec![(chunks, Some((held_epoch, held_key)))];
2996            match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_ok, me.secret_key(), &my_xonly, next) {
2997                Advance::Adopt { new_key } => {
2998                    cur.community_root = new_key;
2999                    cur.root_epoch = next;
3000                    // Archive on adopt: without this, a member who lived through TWO
3001                    // Refoundings loses the middle epoch's public history (only the
3002                    // minter archived it).
3003                    if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
3004                        crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
3005                    }
3006                    advanced = true;
3007                    changed = true;
3008                }
3009                Advance::Removed => {
3010                    if !session.is_valid() {
3011                        return Err("account changed during rekey follow".to_string());
3012                    }
3013                    return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
3014                }
3015                Advance::Stay => {}
3016            }
3017        }
3018
3019        if !advanced {
3020            break;
3021        }
3022    }
3023
3024    if !changed {
3025        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
3026    }
3027    if !session.is_valid() {
3028        return Err("account changed during rekey follow".to_string());
3029    }
3030    // A leave/delete raced this follow: saving would resurrect the community row
3031    // (the save is an upsert) with no floor rows behind it.
3032    if crate::db::community::community_protocol(community.id())?.is_none() {
3033        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
3034    }
3035    crate::db::community::save_community_v2(&cur)?;
3036    Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
3037}
3038
3039/// One scope's catch-up decision from the rekey chunks fetched at its next-epoch
3040/// address.
3041enum Advance {
3042    /// Adopt this fresh key for `next_epoch`.
3043    Adopt { new_key: [u8; 32] },
3044    /// A complete owner rotation at `next_epoch` dropped my blob — I'm removed.
3045    Removed,
3046    /// No owner rotation extends my held epoch (yet) — keep the current key.
3047    Stay,
3048}
3049
3050/// Fetch + parse every seal-verified 3303 chunk at a rekey plane address.
3051async fn fetch_rekey_chunks<T: Transport + ?Sized>(
3052    transport: &T,
3053    relays: &[String],
3054    group: &GroupKey,
3055) -> Result<Vec<rekey::RekeyChunk>, String> {
3056    // A rekey plane address is community_root-derived, so ANY member can seal junk
3057    // 3303s there — a flood (or, organically, a large community's own multi-chunk
3058    // rotation past the newest window) could bury the genuine owner/admin rotation
3059    // in a single fixed page. PAGE backwards (inclusive until + wrap-id dedup, the
3060    // control pager's discipline) so a buried authorized chunk is still recovered;
3061    // the seal + authority filter downstream drops the junk. Bounded — a sustained
3062    // flood past this depth degrades to "adopt one pass late", never a false state.
3063    const REKEY_PAGE: usize = 200;
3064    const REKEY_MAX_PAGES: usize = 6;
3065    let mut out = Vec::new();
3066    let mut seen: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
3067    let mut until: Option<u64> = None;
3068    let mut oldest: Option<u64> = None;
3069    for _ in 0..REKEY_MAX_PAGES {
3070        let query = Query {
3071            kinds: vec![stream::KIND_WRAP],
3072            authors: vec![group.pk_hex()],
3073            until,
3074            limit: Some(REKEY_PAGE),
3075            ..Default::default()
3076        };
3077        let wraps = transport.fetch(&query, relays).await?;
3078        let mut fresh = 0usize;
3079        for w in &wraps {
3080            if !seen.insert(w.id) {
3081                continue;
3082            }
3083            fresh += 1;
3084            let at = w.created_at.as_secs();
3085            if oldest.is_none_or(|o| at < o) {
3086                oldest = Some(at);
3087            }
3088            if let Ok(opened) = stream::open_wrap(w, group) {
3089                if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
3090                    out.push(chunk);
3091                }
3092            }
3093        }
3094        // Drained, or a same-second wall the pager can't step past (second-granular
3095        // until) — either way stop; the accumulated set is what advance_scope folds.
3096        if fresh == 0 || wraps.len() < REKEY_PAGE {
3097            break;
3098        }
3099        match oldest {
3100            Some(o) if o > 0 => until = Some(o),
3101            _ => break,
3102        }
3103    }
3104    Ok(out)
3105}
3106
3107/// Decide how a scope advances from per-addressing-root chunk batches (pure). Each
3108/// batch pairs the chunks fetched under one root with the continuity to demand of
3109/// them: a rotation qualifies when it's rotator-authorized (`rotator_ok`),
3110/// complete, targets the immediate `next_epoch`, and — when I hold a chain —
3111/// extends my exact `(epoch, key)`. A KEYLESS batch (`held` = None) has no chain
3112/// to extend, so it qualifies on authority + completeness alone (CORD-06 §2:
3113/// continuity is "a convergence check, not a secrecy mechanism"; the rotator's
3114/// seal authority is the boundary). Among qualifying rotations carrying my blob
3115/// the lexicographically lowest new key wins (convergent). All complete
3116/// candidates without my blob conclude Removed for a KEYED holder only when one
3117/// came from a rotator who may remove ME (`rotator_may_remove_me`, the CORD-06
3118/// strict-outrank rule) — else Stay; for a keyless holder they merely advance the
3119/// scan cursor (any bit-holder's real rotation is scan progress, never a loss).
3120fn advance_scope(
3121    batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
3122    scope: RekeyScope,
3123    rotator_ok: &dyn Fn(&PublicKey) -> bool,
3124    rotator_may_remove_me: &dyn Fn(&PublicKey) -> bool,
3125    my_sk: &SecretKey,
3126    my_xonly: &[u8; 32],
3127    next_epoch: Epoch,
3128) -> Advance {
3129    let mut winners: Vec<[u8; 32]> = Vec::new();
3130    let mut saw_complete_candidate = false;
3131    let mut saw_outranking_candidate = false;
3132    let keyed = batches.iter().any(|(_, held)| held.is_some());
3133    for (chunks, held) in batches {
3134        let rotations = rekey::collect_rotations(chunks);
3135        for r in &rotations {
3136            if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
3137                continue;
3138            }
3139            if let Some((held_epoch, held_key)) = held {
3140                if r.continuity(*held_epoch, held_key) != Continuity::Extends {
3141                    continue;
3142                }
3143            }
3144            saw_complete_candidate = true;
3145            saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
3146            if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
3147                if let Ok(k) = rekey::open_blob_local(my_sk, &r.rotator, r.scope, r.new_epoch, blob) {
3148                    winners.push(k);
3149                }
3150            }
3151        }
3152    }
3153    if !winners.is_empty() {
3154        // `collect_rotations` correlates on `(rotator, scope, new_epoch, prev_commit)`,
3155        // so a single rotator's blobs merge into ONE rotation (and a retried Refounding
3156        // MINT-OR-REUSES its root, so it never emits two distinct roots to fork on).
3157        // The lowest-key tiebreak engages only for CONCURRENT DISTINCT rotators racing
3158        // the same epoch (separate rotations): every follower converges on the same
3159        // lowest new key. A wrap served under two addressing roots can't double-count:
3160        // each rekey wrap opens under exactly one root's group key.
3161        let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
3162        return Advance::Adopt { new_key: winners[idx] };
3163    }
3164    if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
3165        Advance::Removed
3166    } else {
3167        Advance::Stay
3168    }
3169}
3170
3171#[cfg(test)]
3172mod tests {
3173    use super::super::super::transport::memory::MemoryRelay;
3174    use super::*;
3175    use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
3176
3177    /// A distinct npub-shaped account-dir name (bech32 charset) per counter.
3178    fn account_name(n: u32) -> String {
3179        const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3180        let mut acct = String::from("npub1");
3181        let mut v = n as usize;
3182        for _ in 0..58 {
3183            acct.push(B[v % 32] as char);
3184            v = v / 32 + 7;
3185        }
3186        acct
3187    }
3188
3189    /// One test participant: its identity keys and its isolated account DB dir.
3190    struct Actor {
3191        keys: Keys,
3192        account: String,
3193    }
3194
3195    /// Two participants sharing one relay but isolated per-account DBs — the
3196    /// cross-account harness a real invite/join loop needs. `swap_to` mirrors a
3197    /// live `swap_session`: re-point the DB pool + rebind the identity + clear
3198    /// the per-account id caches, so account A's community is invisible to B
3199    /// until B legitimately joins.
3200    struct TestBed {
3201        _tmp: tempfile::TempDir,
3202        _guard: std::sync::MutexGuard<'static, ()>,
3203        relay: MemoryRelay,
3204        relays: Vec<String>,
3205    }
3206
3207    impl TestBed {
3208        fn new() -> (TestBed, Actor, Actor) {
3209            static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
3210            let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3211            crate::db::close_database();
3212            crate::db::clear_id_caches();
3213            let tmp = tempfile::tempdir().unwrap();
3214            crate::db::set_app_data_dir(tmp.path().to_path_buf());
3215
3216            let mut mk = || {
3217                let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3218                let account = account_name(n);
3219                std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3220                crate::db::set_current_account(account.clone()).unwrap();
3221                crate::db::init_database(&account).unwrap();
3222                Actor { keys: Keys::generate(), account }
3223            };
3224            let owner = mk();
3225            let member = mk();
3226            let _ = crate::state::take_nostr_client();
3227            let bed = TestBed {
3228                _tmp: tmp,
3229                _guard: guard,
3230                relay: MemoryRelay::new(),
3231                relays: vec!["wss://r".to_string()],
3232            };
3233            (bed, owner, member)
3234        }
3235
3236        /// Become `actor`: swap the account DB + identity, as a real session swap.
3237        fn swap_to(&self, actor: &Actor) {
3238            crate::db::set_current_account(actor.account.clone()).unwrap();
3239            crate::db::init_database(&actor.account).unwrap();
3240            crate::db::clear_id_caches();
3241            crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
3242            crate::state::set_my_public_key(actor.keys.public_key());
3243        }
3244    }
3245
3246    /// Legacy single-actor helper (the create/send tests below).
3247    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
3248        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3249        crate::db::close_database();
3250        crate::db::clear_id_caches();
3251        let tmp = tempfile::tempdir().unwrap();
3252        static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
3253        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3254        let acct = account_name(n);
3255        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
3256        crate::db::set_app_data_dir(tmp.path().to_path_buf());
3257        crate::db::set_current_account(acct.clone()).unwrap();
3258        crate::db::init_database(&acct).unwrap();
3259        let _ = crate::state::take_nostr_client();
3260        let owner = Keys::generate();
3261        crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
3262        crate::state::set_my_public_key(owner.public_key());
3263        (tmp, guard, owner)
3264    }
3265
3266    /// A transport that simulates a session swap landing DURING a fetch await —
3267    /// so a join straddling the fetch sees an invalid session and aborts.
3268    struct SwapMidFetch {
3269        inner: MemoryRelay,
3270    }
3271    #[async_trait::async_trait]
3272    impl Transport for SwapMidFetch {
3273        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
3274            self.inner.publish(e, r).await
3275        }
3276        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
3277            self.inner.publish_durable(e, r).await
3278        }
3279        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
3280            let out = self.inner.fetch(q, r).await;
3281            crate::state::bump_session_generation();
3282            out
3283        }
3284    }
3285
3286    /// A transport whose `fetch` returns a FIXED, UNSORTED event list — modelling
3287    /// the production `LiveTransport` union (first-responding relay's batch, no
3288    /// global newest-first sort), which `MemoryRelay` hides by sorting. This is
3289    /// the only harness that can exercise the revocation-race ordering.
3290    struct FixedFetch {
3291        events: Vec<Event>,
3292    }
3293    #[async_trait::async_trait]
3294    impl Transport for FixedFetch {
3295        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
3296            Ok(())
3297        }
3298        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
3299            Ok(())
3300        }
3301        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
3302            Ok(self.events.clone())
3303        }
3304    }
3305
3306    /// Fetch a pending Direct Invite (kind 3313 giftwrap) addressed to `me` — the
3307    /// indexed inbox query CORD-05 §6 defines: `{1059, #p:[me], #k:["3313"]}`.
3308    async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
3309        let q = Query {
3310            kinds: vec![stream::KIND_WRAP],
3311            p_tags: vec![me.to_hex()],
3312            k_tags: vec!["3313".to_string()],
3313            ..Default::default()
3314        };
3315        relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
3316    }
3317
3318    #[tokio::test]
3319    async fn create_persists_and_reloads_a_v2_community() {
3320        let (_tmp, _guard, owner) = init_test_db();
3321        let relay = MemoryRelay::new();
3322        let relays = vec!["wss://r".to_string()];
3323
3324        let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
3325        assert!(created.identity.verify());
3326        assert_eq!(created.owner().unwrap(), owner.public_key());
3327        assert_eq!(created.channels.len(), 1);
3328
3329        // Protocol dispatch sees it as v2, and it reloads byte-faithfully.
3330        assert_eq!(
3331            crate::db::community::community_protocol(created.id()).unwrap(),
3332            Some(crate::community::ConcordProtocol::V2)
3333        );
3334        let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
3335        assert_eq!(loaded.name, "Vectorville");
3336        assert_eq!(loaded.community_root, created.community_root);
3337        assert_eq!(loaded.identity, created.identity);
3338        assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
3339        assert!(!loaded.channels[0].private);
3340
3341        // The genesis control editions + the owner Join landed on the relay.
3342        assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
3343    }
3344
3345    #[tokio::test]
3346    async fn owner_sends_and_reads_back_a_message() {
3347        let (_tmp, _guard, _owner) = init_test_db();
3348        let relay = MemoryRelay::new();
3349        let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
3350        let general = community.channels[0].id;
3351
3352        let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
3353        let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
3354        assert_ne!(id1, id2);
3355
3356        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
3357        let texts: Vec<String> = page
3358            .iter()
3359            .filter_map(|f| match &f.event {
3360                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
3361                _ => None,
3362            })
3363            .collect();
3364        assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
3365    }
3366
3367    #[tokio::test]
3368    async fn a_second_member_reads_the_public_channel_from_the_root() {
3369        // A member who holds the community_root (via an invite bundle, modeled
3370        // here by cloning the community) reads the owner's public-channel message
3371        // — public channels need no key delivery, they derive from the root.
3372        let (_tmp, _guard, _owner) = init_test_db();
3373        let relay = MemoryRelay::new();
3374        let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
3375        let general = community.channels[0].id;
3376        send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
3377
3378        // The "member" reconstructs the same read coordinates from the root.
3379        let member_view = community.clone();
3380        let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
3381        assert_eq!(page.len(), 1);
3382        assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
3383        assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
3384    }
3385
3386    // ── Two-actor end-to-end (the create → invite → join → message loop) ──────
3387
3388    async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
3389        fetch_channel(relay, community, channel, 100)
3390            .await
3391            .unwrap()
3392            .iter()
3393            .filter_map(|f| match &f.event {
3394                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
3395                _ => None,
3396            })
3397            .collect()
3398    }
3399
3400    #[tokio::test]
3401    async fn direct_invite_full_loop_owner_and_member_converse() {
3402        let (bed, owner, member) = TestBed::new();
3403
3404        // Owner creates a community, posts, and Direct-Invites the member's npub.
3405        bed.swap_to(&owner);
3406        let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
3407        let general = community.channels[0].id;
3408        send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
3409        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
3410
3411        // Member (a DIFFERENT account, no prior knowledge) finds + accepts the invite.
3412        bed.swap_to(&member);
3413        assert!(
3414            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
3415            "the member does not hold the community before joining"
3416        );
3417        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
3418        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
3419        assert_eq!(joined.id().0, community.id().0, "joined the same community");
3420        assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
3421        assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
3422
3423        // The member reads the owner's public-channel history and replies.
3424        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
3425        send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
3426
3427        // The owner reads the member's reply.
3428        bed.swap_to(&owner);
3429        assert_eq!(
3430            texts_in(&bed.relay, &community, &general).await,
3431            vec!["owner: welcome!", "member: thanks for the invite"],
3432            "both actors' messages interleave in ms order on the shared channel"
3433        );
3434
3435        // The Guestbook memberlist now folds both participants.
3436        let members = memberlist(&bed.relay, &community).await.unwrap();
3437        assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
3438        assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
3439        assert_eq!(members.len(), 2);
3440    }
3441
3442    #[tokio::test]
3443    async fn public_link_full_loop() {
3444        let (bed, owner, member) = TestBed::new();
3445
3446        bed.swap_to(&owner);
3447        let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
3448        let general = community.channels[0].id;
3449        send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
3450        // Mint a shareable link (a non-stock relay so the fragment carries it).
3451        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3452        assert!(link.url.starts_with("https://vectorapp.io/invite/"));
3453        assert!(link.url.contains('#'), "the fragment carries the token");
3454
3455        // Member joins purely from the URL string.
3456        bed.swap_to(&member);
3457        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
3458        assert_eq!(joined.id().0, community.id().0);
3459        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
3460    }
3461
3462    #[tokio::test]
3463    async fn a_revoked_link_refuses_to_join() {
3464        let (bed, owner, member) = TestBed::new();
3465        bed.swap_to(&owner);
3466        let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
3467        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3468        // Owner retires the link (re-posts the coordinate as a tombstone).
3469        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
3470        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
3471
3472        bed.swap_to(&member);
3473        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
3474        assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
3475    }
3476
3477    #[tokio::test]
3478    async fn an_expired_direct_invite_refuses_to_join() {
3479        let (bed, owner, member) = TestBed::new();
3480        bed.swap_to(&owner);
3481        let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
3482        // Hand-mint an invite that expired in the past.
3483        let inviter = owner.keys.clone();
3484        let mut bundle = bundle_of(&community, Some(inviter.public_key()), Some(1_000), None);
3485        bundle.expires_at = Some(1_000); // unix ms, long past
3486        let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
3487        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
3488
3489        bed.swap_to(&member);
3490        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
3491        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
3492        assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
3493    }
3494
3495    #[tokio::test]
3496    async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
3497        // The revocation-durability fix: if ANY signer-valid tombstone is among the
3498        // fetched events, refuse — even when a Live bundle is returned FIRST (the
3499        // production union has no newest-first sort, so a stale relay's Live can lead).
3500        let (bed, owner, member) = TestBed::new();
3501        bed.swap_to(&owner);
3502        let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
3503        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3504        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
3505
3506        // A relay union that hands back [Live, tombstone] — Live FIRST. Old
3507        // `events.first()` would join the Live; the scan-all fix must refuse.
3508        let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
3509
3510        bed.swap_to(&member);
3511        let err = accept_public_link(&union, &link.url).await.unwrap_err();
3512        assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
3513    }
3514
3515    #[test]
3516    fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
3517        // The accept-side DoS bound: from_bundle (which accept_bundle calls)
3518        // rejects a >256-channel bundle via validate() BEFORE the Vec allocation.
3519        // (The Direct-Invite wire path is additionally bounded by NIP-44's 64KB
3520        // cap, which trips even earlier — but the count guard is the real defense
3521        // for the single-layer public-link bundle.)
3522        let owner = Keys::generate();
3523        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
3524        let hex = crate::simd::hex::bytes_to_hex_32;
3525        let root = [0x11u8; 32];
3526        let mut bundle = CommunityInvite {
3527            community_id: hex(&identity.community_id.0),
3528            owner: hex(&identity.owner_xonly),
3529            owner_salt: hex(&identity.owner_salt),
3530            community_root: hex(&root),
3531            root_epoch: 0,
3532            channels: vec![],
3533            relays: vec!["wss://r".into()],
3534            name: "X".into(),
3535            icon: None,
3536            expires_at: None,
3537            creator_npub: None,
3538            label: None,
3539            extra: Default::default(),
3540        };
3541        bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
3542            .map(|i| {
3543                let mut id = [0u8; 32];
3544                id[..8].copy_from_slice(&(i as u64).to_be_bytes());
3545                invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
3546            })
3547            .collect();
3548        assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
3549    }
3550
3551    #[tokio::test]
3552    async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
3553        // The SessionGuard straddle: a public-link accept fetches then saves. If the
3554        // account swaps in that window, the join must abort — never write A's
3555        // community into B's DB. SwapMidFetch bumps the session generation during
3556        // the fetch await, exactly as a real swap_session would.
3557        let (bed, owner, member) = TestBed::new();
3558        bed.swap_to(&owner);
3559        let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
3560        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3561        // A fresh swap-injecting transport holding the same bundle event.
3562        let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
3563        swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
3564
3565        bed.swap_to(&member);
3566        let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
3567        assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
3568        assert!(
3569            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
3570            "the aborted join wrote nothing to the (member) account DB"
3571        );
3572    }
3573
3574    #[tokio::test]
3575    async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
3576        // The owner is derived from the self-certifying community_id, so the
3577        // memberlist includes them independent of any Guestbook fetch.
3578        let (_tmp, _guard, owner) = init_test_db();
3579        let relay = MemoryRelay::new();
3580        let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
3581        // A memberlist over an EMPTY guestbook (fetch a community-relay-less view)
3582        // still contains the owner.
3583        let empty = MemoryRelay::new();
3584        let members = memberlist(&empty, &community).await.unwrap();
3585        assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
3586    }
3587
3588    #[tokio::test]
3589    async fn an_expiring_minted_invite_refuses_after_the_deadline() {
3590        // The mint path can now produce an expiring invite, and the accept gate
3591        // trips on it (end-to-end through the real service, not a hand-built bundle).
3592        let (bed, owner, member) = TestBed::new();
3593        bed.swap_to(&owner);
3594        let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
3595        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
3596            .await
3597            .unwrap();
3598
3599        bed.swap_to(&member);
3600        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
3601        assert!(
3602            accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
3603            "a minted expiring invite refuses past its deadline"
3604        );
3605    }
3606
3607    #[tokio::test]
3608    async fn a_member_who_leaves_drops_from_the_memberlist() {
3609        let (bed, owner, member) = TestBed::new();
3610        bed.swap_to(&owner);
3611        let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
3612        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
3613
3614        bed.swap_to(&member);
3615        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
3616        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
3617        // Let the leave land strictly after the join.
3618        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
3619        leave_community(&bed.relay, &joined).await.unwrap();
3620
3621        bed.swap_to(&owner);
3622        let members = memberlist(&bed.relay, &community).await.unwrap();
3623        assert!(members.contains(&owner.keys.public_key()));
3624        assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
3625    }
3626
3627    #[tokio::test]
3628    async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
3629        // Multi-account isolation: after the swap, the member's DB holds nothing
3630        // of the owner's community — the dual-stack storage is per-account.
3631        let (bed, owner, member) = TestBed::new();
3632        bed.swap_to(&owner);
3633        let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
3634        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
3635
3636        bed.swap_to(&member);
3637        assert!(
3638            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
3639            "the owner's community must be invisible in the member's account DB"
3640        );
3641        assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
3642    }
3643
3644    // ── Live control-follow ──────────────────────────────────────────────────
3645
3646    /// Publish an owner-grammar channel edition straight to the control plane,
3647    /// signed by `signer` (the owner for a legit edit, a stranger for the
3648    /// authority test). `version`/`deleted` drive add-vs-rename-vs-delete.
3649    /// The entity's current head `self_hash` on the relay (highest version wins),
3650    /// so a helper can chain a new edition the way a real owner client does.
3651    async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
3652        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
3653        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
3654        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
3655        let mut head: Option<(u64, [u8; 32])> = None;
3656        for w in &wraps {
3657            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
3658                if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
3659                    head = Some((ed.version, ed.self_hash));
3660                }
3661            }
3662        }
3663        head.map(|(_, h)| h)
3664    }
3665
3666    async fn publish_channel_edition(
3667        relay: &MemoryRelay,
3668        community: &CommunityV2,
3669        signer: &Keys,
3670        channel_id: &ChannelId,
3671        name: &str,
3672        private: bool,
3673        version: u64,
3674        deleted: bool,
3675    ) {
3676        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
3677        let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
3678        let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
3679        let content = serde_json::to_string(&meta).unwrap();
3680        let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
3681        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
3682        relay.publish(&wrap, &community.relays).await.unwrap();
3683    }
3684
3685    /// Publish an owner-grammar community-metadata edition (rename etc.), chained
3686    /// to the current relay head like a real owner client.
3687    async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
3688        publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
3689    }
3690
3691    /// As [`publish_community_meta`] with an explicit timestamp, for tests that need
3692    /// relay-side newest-first ordering (paging/eviction scenarios).
3693    async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
3694        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
3695        let prev = head_hash_on_relay(relay, community, &community.id().0).await;
3696        let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
3697        let content = serde_json::to_string(&meta).unwrap();
3698        let rumor = control::build_edition_rumor(signer.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, version, prev.as_ref(), &content, at_secs, None);
3699        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
3700        relay.publish(&wrap, &community.relays).await.unwrap();
3701    }
3702
3703    /// Publish a Role edition (vsk 1) signed by `signer`, chained to the current head.
3704    async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
3705        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
3706        let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
3707        let prev = head_hash_on_relay(relay, community, &role_id).await;
3708        let content = crate::community::v2::roles::role_content_json(role).unwrap();
3709        let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, None);
3710        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
3711        relay.publish(&wrap, &community.relays).await.unwrap();
3712    }
3713
3714    /// Publish a Grant edition (vsk 3) signed by `signer`, at grant_locator(cid, member).
3715    async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
3716        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
3717        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
3718        let prev = head_hash_on_relay(relay, community, &eid).await;
3719        let grant = MemberGrant { member: member.to_hex(), role_ids };
3720        let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
3721        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, None);
3722        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
3723        relay.publish(&wrap, &community.relays).await.unwrap();
3724    }
3725
3726    /// Publish a Banlist edition (vsk 4) signed by `signer`, at banlist_locator(cid).
3727    async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
3728        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
3729        let eid = crate::community::v2::derive::banlist_locator(community.id());
3730        let prev = head_hash_on_relay(relay, community, &eid).await;
3731        let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
3732        let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, None);
3733        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
3734        relay.publish(&wrap, &community.relays).await.unwrap();
3735    }
3736
3737    fn admin_role(role_id: &str, perms: u64) -> Role {
3738        Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
3739    }
3740
3741    // ── CORD-04 §1 author-aware fold: a seat-holder (holds community_root, so can seal
3742    // any control edition) must not be able to SUPPRESS a role or grant by forging a
3743    // higher version at its coordinate. Owner-only signers mask this entirely, so every
3744    // attacker below signs as a NON-owner member.
3745
3746    #[tokio::test]
3747    async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
3748        let (bed, owner, attacker) = TestBed::new();
3749        bed.swap_to(&owner);
3750        let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
3751        let victim = Keys::generate().public_key();
3752        grant_admin(&bed.relay, &community, &victim).await.unwrap();
3753
3754        // The admin role sits at a deterministic, publicly-computable coordinate.
3755        let admin_rid = fetch_authority(&bed.relay, &community)
3756            .await
3757            .roles
3758            .roles
3759            .iter()
3760            .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
3761            .unwrap()
3762            .role_id
3763            .clone();
3764        // Attacker forges v2 of that exact role, stripping its powers.
3765        publish_role(
3766            &bed.relay,
3767            &community,
3768            &attacker.keys,
3769            &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
3770            2,
3771        )
3772        .await;
3773
3774        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
3775        assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
3776        assert!(
3777            authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
3778            "the floor advances only to the AUTHORIZED head (owner v1)"
3779        );
3780        assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
3781    }
3782
3783    #[tokio::test]
3784    async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
3785        let (bed, owner, attacker) = TestBed::new();
3786        bed.swap_to(&owner);
3787        let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
3788        let victim = Keys::generate();
3789        grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
3790
3791        // Attacker forges a higher-version EMPTY grant at the victim's grant coordinate.
3792        publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
3793
3794        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
3795        assert!(
3796            authority.roles.is_admin(&victim.public_key().to_hex()),
3797            "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
3798        );
3799    }
3800
3801    #[tokio::test]
3802    async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
3803        let (bed, owner, attacker) = TestBed::new();
3804        bed.swap_to(&owner);
3805        let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
3806        let victim = Keys::generate().public_key();
3807        grant_admin(&bed.relay, &community, &victim).await.unwrap();
3808
3809        // Low-id roles that WOULD evict the admin from a pre-authorize cap — but they're
3810        // unauthorized, so the post-authorize cap never sees them.
3811        for i in 0u8..6 {
3812            let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
3813            publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
3814        }
3815
3816        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
3817        assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
3818        assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
3819    }
3820
3821    /// A canonical (order-independent) fingerprint of an AuthoritySet's authorized
3822    /// roster + banlist — two clients converge iff these match.
3823    fn authority_fingerprint(a: &AuthoritySet) -> String {
3824        let mut roles = a.roles.roles.clone();
3825        roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
3826        let mut grants = a.roles.grants.clone();
3827        for g in &mut grants {
3828            g.role_ids.sort();
3829        }
3830        grants.sort_by(|x, y| x.member.cmp(&y.member));
3831        let banned: Vec<&String> = a.banned.iter().collect();
3832        serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
3833    }
3834
3835    #[tokio::test]
3836    async fn the_v2_authority_fold_is_order_independent() {
3837        // THE core consensus property: two honest clients that receive the SAME
3838        // control editions in DIFFERENT arrival orders must resolve the IDENTICAL
3839        // authorized roster + banlist (author-aware select_authorized + banlist
3840        // fold + cap, all deterministic). A divergence here would fork the
3841        // community's moderation state between honest members.
3842        let (bed, owner, _a) = TestBed::new();
3843        bed.swap_to(&owner);
3844        let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
3845
3846        // A rich control plane: two admins, an extra role, two grants (one of them a
3847        // grant to a member the owner then bans), a banlist, a rename, a channel.
3848        let admin1 = Keys::generate().public_key();
3849        let admin2 = Keys::generate().public_key();
3850        grant_admin(&bed.relay, &community, &admin1).await.unwrap();
3851        grant_admin(&bed.relay, &community, &admin2).await.unwrap();
3852        let mod_rid = "5c".repeat(32);
3853        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
3854        let member = Keys::generate().public_key();
3855        publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
3856        let banned_member = Keys::generate().public_key();
3857        publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
3858        set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
3859        let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
3860        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
3861        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
3862
3863        let editions = fetch_control(&bed.relay, &community).await;
3864        let floors = load_floors(&community);
3865        assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
3866
3867        let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
3868
3869        // Fold under many arrival permutations: reversed, and several deterministic
3870        // rotations/interleavings. Every one must match the baseline.
3871        let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
3872        let mut rev = editions.clone();
3873        rev.reverse();
3874        orders.push(rev);
3875        for shift in [1usize, 3, 5, 7] {
3876            let n = editions.len();
3877            orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
3878        }
3879        // A deterministic "shuffle": interleave from both ends.
3880        let mut zip = Vec::with_capacity(editions.len());
3881        let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
3882        while lo <= hi {
3883            zip.push(editions[lo as usize].clone());
3884            if lo != hi {
3885                zip.push(editions[hi as usize].clone());
3886            }
3887            lo += 1;
3888            hi -= 1;
3889        }
3890        orders.push(zip);
3891
3892        for (i, order) in orders.iter().enumerate() {
3893            let got = authority_fingerprint(&fold_authority(&community, order, &floors));
3894            assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
3895        }
3896        // Sanity: the fingerprint reflects real state (the banned member is out, the
3897        // honest admins are in).
3898        assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
3899        assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
3900    }
3901
3902    /// A transport that ACKs publishes but ERRORS every fetch — a relay outage / withhold.
3903    struct FetchErrors(MemoryRelay);
3904    #[async_trait::async_trait]
3905    impl crate::community::transport::Transport for FetchErrors {
3906        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
3907            self.0.publish(e, r).await
3908        }
3909        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
3910            Err("relay down".to_string())
3911        }
3912        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
3913            self.0.publish_durable(e, r).await
3914        }
3915    }
3916
3917    #[tokio::test]
3918    async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
3919        let (bed, owner, victim) = TestBed::new();
3920        bed.swap_to(&owner);
3921        let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
3922        let victim_hex = victim.keys.public_key().to_hex();
3923        // A ban is persisted locally (as a completed set_banlist + follow leaves it).
3924        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3925        crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
3926
3927        // A relay that ERRORS on fetch must degrade FAIL-SAFE: retain the ban, never
3928        // return an empty banlist (which would silently un-ban on withheld data).
3929        let down = FetchErrors(MemoryRelay::new());
3930        let view = fetch_authority(&down, &community).await;
3931        assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
3932    }
3933
3934    #[tokio::test]
3935    async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
3936        let (bed, owner, _m) = TestBed::new();
3937        bed.swap_to(&owner);
3938        let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
3939        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3940        let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
3941        let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
3942
3943        // Full state on relay1: an Admin role + two grants → both fold + persist as admins.
3944        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
3945        publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
3946        publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
3947        let session = crate::state::SessionGuard::capture();
3948        follow_control(&bed.relay, &community, &session).await.unwrap();
3949        assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
3950
3951        // relay2 serves A's grant but NOT the role (aged out of the window): the fold
3952        // drops both admins yet raises no gap. The completeness gate must RETAIN the
3953        // stored roster rather than persist the lossy one.
3954        let relay2 = MemoryRelay::new();
3955        publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
3956        follow_control(&relay2, &community, &session).await.unwrap();
3957        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
3958        assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
3959    }
3960
3961    #[tokio::test]
3962    async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
3963        // CORD-04 §5: an admin holding MANAGE_METADATA renames the community; once the
3964        // owner revokes the grant, the (now unauthorized) admin's further edit drops
3965        // and the name holds at the last authorized state.
3966        let (_tmp, _guard, owner) = init_test_db();
3967        let relay = MemoryRelay::new();
3968        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
3969        let admin = Keys::generate();
3970        let rid = "a1".repeat(32);
3971        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
3972        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
3973        publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
3974
3975        let session = SessionGuard::capture();
3976        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
3977        assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
3978
3979        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke
3980        publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
3981        let _ = follow_control(&relay, &community, &session).await.unwrap();
3982        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
3983        assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
3984    }
3985
3986    #[tokio::test]
3987    async fn a_roleless_member_cannot_edit_metadata() {
3988        let (_tmp, _guard, _owner) = init_test_db();
3989        let relay = MemoryRelay::new();
3990        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
3991        let stranger = Keys::generate();
3992        publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
3993        let session = SessionGuard::capture();
3994        assert!(
3995            follow_control(&relay, &community, &session).await.unwrap().is_none(),
3996            "a roleless member's metadata edit never folds"
3997        );
3998    }
3999
4000    #[tokio::test]
4001    async fn a_self_signed_grant_is_not_authority() {
4002        // The self-promotion defense: a member self-signs both a role and a grant of
4003        // it to themselves. authorize_delegation drops both (their signer never traces
4004        // to the owner), so their metadata edit stays unauthorized.
4005        let (_tmp, _guard, _owner) = init_test_db();
4006        let relay = MemoryRelay::new();
4007        let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
4008        let rogue = Keys::generate();
4009        let rid = "b2".repeat(32);
4010        publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4011        publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
4012        publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
4013        let session = SessionGuard::capture();
4014        assert!(
4015            follow_control(&relay, &community, &session).await.unwrap().is_none(),
4016            "a self-signed grant confers no authority"
4017        );
4018    }
4019
4020    #[tokio::test]
4021    async fn the_banlist_is_enforced_only_from_a_ban_holder() {
4022        let (_tmp, _guard, owner) = init_test_db();
4023        let relay = MemoryRelay::new();
4024        let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
4025        let target = "cc".repeat(32);
4026
4027        // A non-BAN-holder's banlist edition is folded but NOT enforced.
4028        let rogue = Keys::generate();
4029        publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
4030        let floors = load_floors(&community);
4031        let editions = fetch_control(&relay, &community).await;
4032        let authority = fold_authority(&community, &editions, &floors);
4033        assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
4034
4035        // The owner (supreme, holds BAN) bans the target: now enforced.
4036        publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
4037        let editions = fetch_control(&relay, &community).await;
4038        let authority = fold_authority(&community, &editions, &floors);
4039        assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
4040    }
4041
4042    #[tokio::test]
4043    async fn a_banned_admin_loses_all_authority() {
4044        // CORD-04 §4: a banned npub vanishes — even holding an un-stripped grant, a
4045        // banned admin's authority is dropped and their edits refused.
4046        let (_tmp, _guard, owner) = init_test_db();
4047        let relay = MemoryRelay::new();
4048        let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
4049        let admin = Keys::generate();
4050        let rid = "e5".repeat(32);
4051        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
4052        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
4053        publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; // ban, grant left intact
4054        publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
4055
4056        let session = SessionGuard::capture();
4057        assert!(
4058            follow_control(&relay, &community, &session).await.unwrap().is_none(),
4059            "a banned admin's edit is dropped even with an unstripped grant"
4060        );
4061        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4062        assert!(authority.banned.contains(&admin.public_key().to_hex()));
4063        assert!(
4064            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
4065            "a banned admin holds no bit"
4066        );
4067    }
4068
4069    #[tokio::test]
4070    async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
4071        // CORD-04 §3/§5: BAN needs the bit AND a strict outrank of the target. A mod
4072        // (pos 2, holds BAN) can ban a lower member but NOT a superior admin (pos 1)
4073        // and NOT the owner (supreme, unbannable).
4074        let (_tmp, _guard, owner) = init_test_db();
4075        let relay = MemoryRelay::new();
4076        let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
4077        let admin = Keys::generate();
4078        let moder = Keys::generate();
4079        let stranger = Keys::generate();
4080        let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
4081        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;
4082        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;
4083        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
4084        publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
4085        publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
4086
4087        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4088        assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
4089        assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
4090        assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
4091    }
4092
4093    #[tokio::test]
4094    async fn an_unauthorized_higher_banlist_cannot_unban() {
4095        // CORD-04 §4 anti-roster fail-CLOSED: a rogue's higher-version empty banlist
4096        // must not erase the owner's ban (author-aware head selection + persisted
4097        // banlist retention).
4098        let (_tmp, _guard, owner) = init_test_db();
4099        let relay = MemoryRelay::new();
4100        let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
4101        let target = "cc".repeat(32);
4102        publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
4103        let session = SessionGuard::capture();
4104        follow_control(&relay, &community, &session).await.unwrap(); // persists the ban
4105
4106        let rogue = Keys::generate();
4107        publish_banlist(&relay, &community, &rogue, &[], 2).await; // unauthorized higher, empty
4108        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4109        assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
4110    }
4111
4112    #[tokio::test]
4113    async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
4114        // CORD-02 §8: create publishes the 13302; a fresh device (community dropped
4115        // locally, the 13302 + genesis still on the relay) rehydrates it on sync.
4116        let (_tmp, _guard, _owner) = init_test_db();
4117        let relay = MemoryRelay::new();
4118        let relays = vec!["wss://r".to_string()];
4119        let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
4120        crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
4121        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
4122
4123        let rehydrated = sync_community_list(&relay, &relays).await.unwrap();
4124        assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
4125        assert_eq!(rehydrated[0].id().0, community.id().0);
4126        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
4127    }
4128
4129    #[tokio::test]
4130    async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
4131        let (_tmp, _guard, _owner) = init_test_db();
4132        let relay = MemoryRelay::new();
4133        let relays = vec!["wss://r".to_string()];
4134        let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
4135        leave_community(&relay, &community).await.unwrap(); // tombstones the 13302 + deletes
4136
4137        let rehydrated = sync_community_list(&relay, &relays).await.unwrap();
4138        assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
4139    }
4140
4141    #[tokio::test]
4142    async fn accepting_the_same_bundle_twice_is_idempotent() {
4143        // A bot restart or a duplicate invite delivery: accepting the SAME bundle
4144        // again must upsert cleanly — same community_id, no duplicate channels, no
4145        // corruption, the keys unchanged.
4146        let (bed, owner, member) = TestBed::new();
4147        bed.swap_to(&owner);
4148        let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
4149        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
4150        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4151        let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4152
4153        bed.swap_to(&member);
4154        let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
4155        let channels_after_first = first.channels.len();
4156        let root_after_first = first.community_root;
4157
4158        // Accept the identical bundle again (restart / redelivery).
4159        let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
4160        assert_eq!(second.id().0, first.id().0, "same community_id");
4161        assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
4162        assert_eq!(second.community_root, root_after_first, "root unchanged");
4163
4164        // The persisted state is a single clean community with the expected channels.
4165        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4166        assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
4167        assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
4168    }
4169
4170    #[tokio::test]
4171    async fn a_severed_member_can_be_unbanned_and_re_admitted() {
4172        // The full moderation HEAL lifecycle: ban (banlist + grant strip + refound)
4173        // severs a member; the owner then unbans + sends a FRESH invite carrying the
4174        // NEW root; the member rejoins at the new epoch and converses again. Proves
4175        // a ban is reversible end-to-end, not a one-way door.
4176        let (bed, owner, member) = TestBed::new();
4177        bed.swap_to(&owner);
4178        let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
4179        let general = community.channels[0].id;
4180        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
4181        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
4182
4183        bed.swap_to(&member);
4184        let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4185        let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
4186        assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
4187
4188        // Owner bans the member (CORD-04 §6 three-removal) → refound severs them.
4189        bed.swap_to(&owner);
4190        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
4191        grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
4192        community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
4193        assert_eq!(community.root_epoch, Epoch(1));
4194        send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
4195
4196        // The member's follow concludes severance (no blob at the new epoch).
4197        bed.swap_to(&member);
4198        let session = SessionGuard::capture();
4199        assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
4200
4201        // Owner unbans + re-invites: build the fresh epoch-1 bundle (accept it
4202        // directly, so the test picks the NEW invite unambiguously rather than an
4203        // arbitrary one of the two pending 3313s).
4204        bed.swap_to(&owner);
4205        set_banlist(&bed.relay, &community, &[]).await.unwrap();
4206        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4207        assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
4208        let fresh_bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4209
4210        // Member accepts the fresh invite → rejoins at epoch 1, reads current + posts.
4211        bed.swap_to(&member);
4212        let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
4213        assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
4214        assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
4215        let seen = texts_in(&bed.relay, &rejoined, &general).await;
4216        assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
4217        send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
4218
4219        bed.swap_to(&owner);
4220        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4221        assert!(
4222            texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
4223            "the re-admitted member converses again at the new epoch"
4224        );
4225        // And they're back in the memberlist.
4226        let members = memberlist(&bed.relay, &community).await.unwrap();
4227        assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
4228    }
4229
4230    #[tokio::test]
4231    async fn dissolution_blocks_a_join() {
4232        // CORD-02 §9: the owner dissolves; a would-be joiner resolves the grave and
4233        // refuses to join.
4234        let (bed, owner, member) = TestBed::new();
4235        bed.swap_to(&owner);
4236        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
4237        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4238        let bundle_json = serde_json::to_string(&bundle).unwrap();
4239        dissolve_community(&bed.relay, &community).await.unwrap();
4240        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
4241
4242        bed.swap_to(&member);
4243        let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
4244        assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
4245    }
4246
4247    #[tokio::test]
4248    async fn only_the_owner_can_dissolve() {
4249        let (bed, owner, member) = TestBed::new();
4250        bed.swap_to(&owner);
4251        let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
4252        bed.swap_to(&member);
4253        assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
4254        assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
4255    }
4256
4257    #[tokio::test]
4258    async fn a_foreign_tombstone_is_not_death() {
4259        // A non-owner sealing the dissolved plane is noise (verify_dissolved is
4260        // owner-gated), so the community is not treated as dead.
4261        let (_tmp, _guard, _owner) = init_test_db();
4262        let relay = MemoryRelay::new();
4263        let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
4264        let rogue = Keys::generate();
4265        let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
4266        let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
4267        relay.publish(&wrap, &community.relays).await.unwrap();
4268        assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
4269    }
4270
4271    #[tokio::test]
4272    async fn a_public_channel_reads_history_across_a_refounding() {
4273        // CORD-03 §3: after a Refounding rolls the base root, a Public channel's
4274        // pre-rotation messages stay readable (the prior epoch's root is archived and
4275        // the read fans out across held epochs).
4276        let (_tmp, _guard, _owner) = init_test_db();
4277        let relay = MemoryRelay::new();
4278        let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
4279        let general = community.channels[0].id;
4280        send_message(&relay, &community, &general, "before the refounding").await.unwrap();
4281
4282        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
4283        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
4284        send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
4285
4286        let texts = texts_in(&relay, &refounded, &general).await;
4287        assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
4288        assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
4289    }
4290
4291    #[tokio::test]
4292    async fn refounding_aborts_when_control_state_is_withheld() {
4293        // B1 coverage gate (CORD-06 §3): a relay serving none of the committed control
4294        // heads must ABORT the Refounding — never silently drop state (e.g. unban a
4295        // member at the new epoch a fresh joiner bootstraps).
4296        let (_tmp, _guard, owner) = init_test_db();
4297        let relay = MemoryRelay::new();
4298        let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
4299        publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
4300        let session = SessionGuard::capture();
4301        follow_control(&relay, &community, &session).await.unwrap(); // seed the banlist floor
4302
4303        // Re-point the held community to an EMPTY relay + save, so the Refounding (which
4304        // reloads fresh state) fetches none of the committed heads.
4305        let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4306        moved.relays = vec!["wss://empty".into()];
4307        crate::db::community::save_community_v2(&moved).unwrap();
4308
4309        let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
4310        assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
4311        assert_eq!(
4312            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
4313            Epoch(0),
4314            "the epoch did NOT advance (zero published state)"
4315        );
4316    }
4317
4318    #[tokio::test]
4319    async fn refounding_rolls_the_root_and_severs_a_removed_member() {
4320        // CORD-06 §3: the owner re-founds, removing a member. The base root rolls, the
4321        // epoch advances, and the removed member's rekey-follow concludes they're cut.
4322        let (bed, owner, member) = TestBed::new();
4323        bed.swap_to(&owner);
4324        let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
4325        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4326        let bundle_json = serde_json::to_string(&bundle).unwrap();
4327        bed.swap_to(&member);
4328        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4329
4330        bed.swap_to(&owner);
4331        let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
4332        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
4333        assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
4334        // The owner still reads the compacted control plane at the new epoch.
4335        let session = SessionGuard::capture();
4336        assert_eq!(
4337            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
4338            Epoch(1),
4339            "the owner committed the new epoch"
4340        );
4341
4342        // The removed member, following rekeys, is severed (no blob in the rotation).
4343        bed.swap_to(&member);
4344        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
4345        assert!(follow.self_removed, "the removed member is cut by the re-founding");
4346    }
4347
4348    #[tokio::test]
4349    async fn only_the_owner_re_founds_even_a_ban_holding_admin_cannot() {
4350        // The receive side (advance_scope) honors ONLY the owner's rotation, so the
4351        // SEND is owner-only — a non-owner BAN-holder's Refounding would fork onto a
4352        // root nobody follows. Owner grants a member BAN; they still can't re-found.
4353        let (bed, owner, member) = TestBed::new();
4354        bed.swap_to(&owner);
4355        let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
4356        let rid = "b0".repeat(32);
4357        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
4358        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
4359        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4360        let bundle_json = serde_json::to_string(&bundle).unwrap();
4361        bed.swap_to(&member);
4362        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4363        assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a non-owner BAN-holder can't re-found");
4364    }
4365
4366    #[tokio::test]
4367    async fn a_retried_refounding_reuses_the_same_root() {
4368        // B1 idempotency: minting for the same (scope, epoch) twice yields the SAME
4369        // root, so a retried Refounding re-delivers one root — never a double-mint fork.
4370        let (_tmp, _guard, _owner) = init_test_db();
4371        let relay = MemoryRelay::new();
4372        let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
4373        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4374        let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
4375        let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
4376        assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
4377    }
4378
4379    #[tokio::test]
4380    async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
4381        // CORD-05 §5: the Registry is the Public/Private source of truth. Minting a
4382        // link publishes it (Public); retiring the last link empties it (Private).
4383        let (_tmp, _guard, _owner) = init_test_db();
4384        let relay = MemoryRelay::new();
4385        let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
4386        assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
4387
4388        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
4389        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
4390        let list = fetch_invite_list(&relay, &community.relays).await.expect("the 13303 list was published");
4391        assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
4392
4393        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
4394        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
4395        assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
4396        let after = fetch_invite_list(&relay, &community.relays).await.unwrap();
4397        assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
4398    }
4399
4400    #[tokio::test]
4401    async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
4402        // The CREATE_INVITE gate: a rogue publishing a registry can't fake Public.
4403        let (_tmp, _guard, owner) = init_test_db();
4404        let relay = MemoryRelay::new();
4405        let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
4406        let rogue = Keys::generate();
4407        // Rogue publishes a registry edition at THEIR coordinate with a fake signer.
4408        let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
4409        let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
4410        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4411        let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
4412        let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
4413        relay.publish(&wrap, &community.relays).await.unwrap();
4414        let _ = owner;
4415        assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
4416    }
4417
4418    #[tokio::test]
4419    async fn full_lifecycle_e2e() {
4420        // The whole stack end to end across two accounts: create -> Public link ->
4421        // owner grants an admin -> member joins + reads history -> admin edits metadata
4422        // (authorized fold) -> owner bans the member (CORD-04 §6: banlist + strip +
4423        // Refounding) -> the banned member is severed AND stays banned across the new
4424        // epoch -> pre-ban history still reads -> owner dissolves -> sealed.
4425        let (bed, owner, member) = TestBed::new();
4426
4427        bed.swap_to(&owner);
4428        let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
4429        let general = community.channels[0].id;
4430        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
4431
4432        // Public link → the community reads Public.
4433        let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
4434        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
4435
4436        // Owner defines + grants an Admin role (MANAGE_METADATA among the bits).
4437        let rid = "aa".repeat(32);
4438        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4439        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
4440
4441        // Member joins from the bundle + reads the owner's message.
4442        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4443        let bundle_json = serde_json::to_string(&bundle).unwrap();
4444        bed.swap_to(&member);
4445        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4446        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
4447        // The admin renames the community.
4448        publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
4449
4450        // Owner follows: the admin's rename folds (authorized).
4451        bed.swap_to(&owner);
4452        let session = SessionGuard::capture();
4453        let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
4454        assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
4455
4456        // Ban the member (the three-removal composition, in order).
4457        set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
4458        grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
4459        let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
4460        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
4461        // The ban survives the Refounding (the banlist head compacted forward).
4462        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
4463        assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
4464        // Pre-ban history still reads across the new epoch.
4465        assert!(
4466            texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
4467            "pre-refounding history stays readable"
4468        );
4469
4470        // The banned member's rekey-follow concludes they're severed.
4471        bed.swap_to(&member);
4472        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
4473        assert!(follow.self_removed, "the banned member is cryptographically cut");
4474
4475        // Owner dissolves → sealed.
4476        bed.swap_to(&owner);
4477        dissolve_community(&bed.relay, &refounded).await.unwrap();
4478        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
4479    }
4480
4481    /// The deep two-account e2e the way a real deployment runs: owner (A) + member (B)
4482    /// over one shared relay, create → channels (public + private) → converse both ways →
4483    /// persist (get_messages-level) → react/edit/delete → moderate (ban/unban) → dissolve.
4484    /// Every account, community, channel, and action is LOGGED (run with --nocapture) so it
4485    /// doubles as a reference transcript and a re-runnable regression.
4486    #[tokio::test]
4487    async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
4488        // A member forges a higher-version role edition at the admin coordinate before a
4489        // refounding. The compaction must carry the AUTHORIZED floor head, not the
4490        // author-blind version tip — else the forgery is re-anchored, honest folders drop
4491        // it, and the admin role vanishes at the new epoch (silent suppression).
4492        let (bed, owner, member) = TestBed::new();
4493        let attacker = Keys::generate();
4494        bed.swap_to(&owner);
4495        let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
4496        let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
4497        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4498        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
4499        // Owner folds → the authorized role/grant heads are floored.
4500        let session = SessionGuard::capture();
4501        follow_control(&bed.relay, &community, &session).await.unwrap();
4502        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
4503
4504        // The attacker (a non-owner) forges v2 of the admin role, chaining onto v1.
4505        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;
4506
4507        // Owner refounds (keeping everyone).
4508        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
4509        assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
4510
4511        // Post-refound, the admin role SURVIVES (the authorized floor head was carried).
4512        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
4513        assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
4514    }
4515
4516    #[tokio::test]
4517    async fn memberlist_survives_a_refounding_via_the_snapshot() {
4518        // A silent survivor (didn't re-post at the new epoch) must stay in the memberlist
4519        // after a refounding — the owner's 3312 snapshot re-seeds them (CORD-02 §5).
4520        let (bed, owner, member) = TestBed::new();
4521        bed.swap_to(&owner);
4522        let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
4523
4524        // Member joins (a Guestbook Join at epoch 0).
4525        let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4526        bed.swap_to(&member);
4527        accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
4528        bed.swap_to(&owner);
4529        assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
4530
4531        // Owner refounds keeping everyone (removed = []); survivors are snapshotted to epoch 1.
4532        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
4533        assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
4534
4535        // The member is STILL a member at epoch 1 purely via the snapshot (never re-posted).
4536        let members = memberlist(&bed.relay, &refounded).await.unwrap();
4537        assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
4538        assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
4539    }
4540
4541    #[tokio::test]
4542    async fn e2e_two_accounts_channels_converse_moderate() {
4543        use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
4544        use nostr_sdk::prelude::ToBech32;
4545        let (bed, a, b) = TestBed::new();
4546        let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
4547        let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
4548        println!("\n===== Concord v2 deep e2e =====");
4549        println!("[acct] A (owner)  = {a_npub}");
4550        println!("[acct] B (member) = {b_npub}");
4551
4552        // ── A creates the community + a PRIVATE channel + two extra PUBLIC channels ──
4553        bed.swap_to(&a);
4554        let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
4555        let general = community.channels[0].id;
4556        println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
4557
4558        // A PRIVATE channel via the REAL create path: an independent key minted at
4559        // channel-epoch 1, delivered over the rekey plane (A is the only member yet),
4560        // then announced (vsk 2) — later carried to B in the join bundle.
4561        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
4562        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4563        let priv_ch = community.channel(&priv_id).unwrap();
4564        assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
4565        println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
4566
4567        // Two more PUBLIC channels via the real create path.
4568        let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
4569        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4570        let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
4571        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4572        println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
4573        assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
4574
4575        // A talks in a few channels.
4576        let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
4577        send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
4578        send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
4579        println!("[msg] A posted in #general / #announcements / #mods");
4580
4581        // ── A grants B admin, mints a public link, B joins from the bundle ──
4582        let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
4583        publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
4584        publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
4585        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4586        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
4587        println!("[invite] granted B @admin · minted link {}", link.url);
4588
4589        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.keys.public_key()), None, None)).unwrap();
4590        bed.swap_to(&b);
4591        let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4592        println!("[join] B joined; sees {} channels", b_view.channels.len());
4593        assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
4594        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");
4595        assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
4596        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");
4597        // B folds the control plane (persisting the roster) — the live worker does
4598        // this right after any join; B's admin standing gates B's channel ops below.
4599        let session_b = SessionGuard::capture();
4600        if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
4601            b_view = fresh;
4602        }
4603        println!("[follow] B folded control (roster persisted: B is @admin)");
4604
4605        // ── Conversation both ways + persistence (get_messages-level) ──
4606        send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
4607        send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
4608        println!("[msg] B replied in #general + #mods");
4609        // Persist B's own #general view into the shared store (what sync/live ingest does)
4610        // and confirm it reads back via STATE — get_messages parity.
4611        let my_pk = b.keys.public_key();
4612        let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
4613        for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
4614            let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
4615            if let Some(o) = outcome { persist_chat(&gh, &o).await; }
4616        }
4617        assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
4618        println!("[persist] #general history persisted into the shared events store");
4619
4620        // B (admin) reacts to + the author edits/deletes — the chat-op surface.
4621        send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
4622        bed.swap_to(&a);
4623        let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
4624        send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
4625        let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
4626        send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
4627        println!("[ops] reaction + edit + delete round-tripped");
4628
4629        // ── B creates a channel as admin, A folds it in ──
4630        bed.swap_to(&b);
4631        let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
4632        println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
4633        bed.swap_to(&a);
4634        let session = SessionGuard::capture();
4635        if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
4636            community = updated;
4637        }
4638        assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
4639        println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
4640
4641        // ── A creates a SECOND private channel while B is already a member: B is a
4642        // recipient of the creation delivery, so B keys up from the rekey plane
4643        // (keyless record → cursor walk → blob) with no bundle involved ──
4644        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
4645        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4646        send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
4647        println!("[channel] +private #vault {} (B is a live member — delivery via rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0));
4648        bed.swap_to(&b);
4649        let session_b2 = SessionGuard::capture();
4650        if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
4651            b_view = fresh;
4652        }
4653        let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
4654        assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
4655        let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
4656        b_view = rf.updated.expect("the rekey walk adopts the creation delivery");
4657        let ch = b_view.channel(&vault).expect("still recorded");
4658        assert!(ch.key.is_some() && ch.epoch == Epoch(1), "B adopted the epoch-1 key from the creation crate");
4659        assert!(
4660            texts_in(&bed.relay, &b_view, &vault).await.contains(&"A: vault is open".to_string()),
4661            "B reads the private history with the ADOPTED key"
4662        );
4663        send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.unwrap();
4664        bed.swap_to(&a);
4665        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4666        assert!(
4667            texts_in(&bed.relay, &community, &vault).await.contains(&"B: in the vault".to_string()),
4668            "A reads B's reply on the natively-created private channel"
4669        );
4670        println!("[private] B adopted #vault via rekey plane; two-way private conversation verified");
4671
4672        // ── Members ──
4673        let members = memberlist(&bed.relay, &community).await.unwrap();
4674        let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
4675        assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
4676        println!("[members] {} members: A + B present", members.len());
4677
4678        // ── Moderate: ban B (banlist + strip + refound), verify severance + survival ──
4679        set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
4680        grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
4681        let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
4682        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
4683        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
4684        assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
4685        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");
4686        assert!(
4687            texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
4688            "PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
4689        );
4690        println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
4691        // B concludes it's severed.
4692        bed.swap_to(&b);
4693        let session_b3 = SessionGuard::capture();
4694        assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
4695        println!("[ban] B's rekey-follow: self_removed = true (severed)");
4696
4697        // ── Unban: A lifts the ban ──
4698        bed.swap_to(&a);
4699        set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
4700        let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
4701        assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
4702        println!("[unban] B removed from the banlist (re-invitable)");
4703
4704        // ── Dissolve ──
4705        dissolve_community(&bed.relay, &refounded).await.unwrap();
4706        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
4707        println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
4708    }
4709
4710    /// The same scenario on a REAL relay with TWO throwaway accounts, off by default. It
4711    /// LOGS both nsecs (+ every id) so you can inspect the run and RE-RUN against the same
4712    /// accounts by exporting `VECTOR_E2E_NSEC_A` / `_B`. Set `VECTOR_E2E_LOG=<path>` to also
4713    /// append the transcript to a file, `VECTOR_E2E_RELAY=<url>` to pick the relay.
4714    ///   cargo test -p vector-core -- --ignored --nocapture live_e2e_two_accounts
4715    #[tokio::test]
4716    #[ignore]
4717    async fn live_e2e_two_accounts() {
4718        use crate::community::transport::LiveTransport;
4719        use nostr_sdk::prelude::{ClientBuilder, RelayOptions, ToBech32};
4720
4721        let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
4722        let relays = vec![relay.clone()];
4723        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
4724        crate::db::close_database();
4725        crate::db::clear_id_caches();
4726        let tmp = tempfile::tempdir().unwrap();
4727        crate::db::set_app_data_dir(tmp.path().to_path_buf());
4728
4729        // Throwaway (or bring-your-own via env for a re-run against the same accounts).
4730        let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
4731        let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
4732
4733        let log = |line: String| {
4734            println!("{line}");
4735            if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
4736                use std::io::Write;
4737                if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
4738                    let _ = writeln!(f, "{line}");
4739                }
4740            }
4741        };
4742        log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
4743        log(format!("VECTOR_E2E_NSEC_A={}  ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
4744        log(format!("VECTOR_E2E_NSEC_B={}  ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
4745
4746        for k in [&a, &b] {
4747            let npub = k.public_key().to_bech32().unwrap();
4748            std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
4749            crate::db::set_current_account(npub.clone()).unwrap();
4750            crate::db::init_database(&npub).unwrap();
4751        }
4752        // One relay connection: a v2 wrap is pre-signed (ephemeral p-key) and its seal is
4753        // signed by MY_SECRET_KEY, so publishing needs no per-account client signer.
4754        let client = ClientBuilder::new().signer(a.clone()).build();
4755        client.pool().add_relay(relay.as_str(), RelayOptions::default()).await.ok();
4756        client.connect().await;
4757        crate::state::set_nostr_client(client);
4758        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
4759        let become_acct = |k: &Keys| {
4760            let npub = k.public_key().to_bech32().unwrap();
4761            crate::db::set_current_account(npub.clone()).unwrap();
4762            crate::db::init_database(&npub).unwrap();
4763            crate::db::clear_id_caches();
4764            crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
4765            crate::state::set_my_public_key(k.public_key());
4766        };
4767        let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
4768
4769        // A: create + a channel + grant B admin + mint link.
4770        become_acct(&a);
4771        let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
4772        let general = community.channels[0].id;
4773        log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
4774        send_message(&transport, &community, &general, "A: live hello").await.expect("send");
4775        let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
4776        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4777        log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
4778        grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
4779        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
4780        log(format!("[invite] B granted @admin · link {}", link.url));
4781        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.public_key()), None, None)).unwrap();
4782        settle().await;
4783
4784        // B: join + read A's history + reply.
4785        become_acct(&b);
4786        let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
4787        log(format!("[join] B joined; {} channels", b_view.channels.len()));
4788        settle().await;
4789        let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
4790        let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
4791        log(format!("[read] B sees #general: {seen:?}"));
4792        assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
4793        send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
4794
4795        // B posts a NIP-22 kind-1111 THREADED REPLY to A's message (the shape Armada
4796        // sends) directly onto the chat plane — proving the cross-client thread
4797        // RECEIVE path works live, not just in the offline fixture.
4798        let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
4799        let hello_id = hello.event.opened().rumor_id.to_hex();
4800        let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
4801        let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
4802        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());
4803        let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
4804        transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
4805        log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
4806        settle().await;
4807
4808        // A reads the thread reply back, rendered inline with A's message as parent.
4809        become_acct(&a);
4810        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4811        let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
4812        let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
4813        if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
4814            assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
4815            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");
4816        } else {
4817            panic!("the 1111 parsed as a Message");
4818        }
4819        log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
4820        become_acct(&b);
4821        settle().await;
4822
4823        // A: create a PRIVATE channel while B is already a member — B is a recipient
4824        // of the creation delivery, so B keys up from the rekey plane over the real
4825        // relay (no bundle involved), then the two converse on it.
4826        become_acct(&a);
4827        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4828        let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
4829        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4830        send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
4831        log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
4832        settle().await;
4833
4834        become_acct(&b);
4835        let session_b = SessionGuard::capture();
4836        let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
4837        if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
4838            b_view = fresh;
4839        }
4840        if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
4841            b_view = fresh;
4842        }
4843        let vch = b_view.channel(&vault).expect("B folded the vault");
4844        assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
4845        let vseen = texts_in(&transport, &b_view, &vault).await;
4846        log(format!("[read] B sees #vault: {vseen:?}"));
4847        assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
4848        send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
4849        settle().await;
4850
4851        become_acct(&a);
4852        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4853        assert!(
4854            texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
4855            "A reads B's private reply"
4856        );
4857        log("[private] two-way #vault conversation over the live relay".to_string());
4858
4859        // A: ban B (three-removal) + dissolve.
4860        set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
4861        grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
4862        let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
4863        log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
4864        settle().await;
4865        dissolve_community(&transport, &refounded).await.expect("dissolve");
4866        log("[dissolve] community sealed".to_string());
4867        log("===== LIVE e2e PASS =====".to_string());
4868    }
4869
4870    #[tokio::test]
4871    async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
4872        // The tombstone rides its own public plane, watched live — an OFFLINE
4873        // member's catch-up must fetch it too, or they follow (and post into) a
4874        // grave forever.
4875        let (bed, owner, member) = TestBed::new();
4876        bed.swap_to(&owner);
4877        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
4878        let general = community.channels[0].id;
4879        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
4880
4881        bed.swap_to(&member);
4882        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4883        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
4884
4885        // The owner dissolves while the member sleeps.
4886        bed.swap_to(&owner);
4887        dissolve_community(&bed.relay, &community).await.unwrap();
4888
4889        // The member's catch-up learns of the death, seals, and refuses to post.
4890        bed.swap_to(&member);
4891        let session = SessionGuard::capture();
4892        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
4893        assert!(follow.dissolved, "the catch-up surfaces the tombstone");
4894        assert!(!follow.self_removed && follow.updated.is_none());
4895        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
4896        assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
4897        let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
4898        assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
4899        // Subsequent follows take the local fast path — still dissolved, no churn.
4900        let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
4901        assert!(again.dissolved && again.updated.is_none());
4902    }
4903
4904    #[tokio::test]
4905    async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
4906        // Scale stress: MANY private channels, each rotated on every Refounding.
4907        // A member offline across two refoundings must converge on all of them
4908        // (the per-channel rotation fan in refound + the follow's channel×root×step
4909        // loops stay bounded) with every channel's history readable.
4910        const PRIV_CHANNELS: usize = 6;
4911        let (bed, owner, member) = TestBed::new();
4912        bed.swap_to(&owner);
4913        let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
4914        let mut priv_ids = Vec::new();
4915        for i in 0..PRIV_CHANNELS {
4916            let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
4917            community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4918            send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
4919            priv_ids.push(id);
4920        }
4921        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4922
4923        // Member joins at epoch 0 with all channel keys, then goes offline.
4924        bed.swap_to(&member);
4925        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4926        assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
4927
4928        // Two refoundings (each rotates the base + every private channel).
4929        bed.swap_to(&owner);
4930        for epoch in 1..=2u64 {
4931            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
4932            assert_eq!(community.root_epoch, Epoch(epoch));
4933            for id in &priv_ids {
4934                send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
4935            }
4936        }
4937
4938        // Member returns: bounded follow to quiescence.
4939        bed.swap_to(&member);
4940        let session = SessionGuard::capture();
4941        let mut passes = 0;
4942        loop {
4943            passes += 1;
4944            assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
4945            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
4946            let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
4947            assert!(!rk.self_removed);
4948            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
4949            let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
4950            if rk.updated.is_none() && ctl.is_none() {
4951                break;
4952            }
4953        }
4954        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
4955        assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
4956        // Every private channel converged to the owner's current key + reads all epochs.
4957        for id in &priv_ids {
4958            let mine = caught_up.channel(id).expect("channel survived");
4959            let theirs = community.channel(id).unwrap();
4960            assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
4961            assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
4962            let texts = texts_in(&bed.relay, &caught_up, id).await;
4963            let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
4964            assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
4965            for epoch in 1..=2u64 {
4966                assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
4967            }
4968        }
4969    }
4970
4971    #[tokio::test]
4972    async fn an_offline_member_catches_up_across_three_refoundings() {
4973        // The deep offline-online scenario: a member sleeps through THREE
4974        // Refoundings, per-refound private-channel rotations, a mid-life private
4975        // channel CREATED while they slept, a public channel, a rename, and a
4976        // ban — then returns and converges by follow alone (no rejoin).
4977        use nostr_sdk::prelude::ToBech32;
4978        let (bed, owner, member) = TestBed::new();
4979        bed.swap_to(&owner);
4980        let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
4981        let general = community.channels[0].id;
4982        let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
4983        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4984        send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
4985        send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
4986        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4987
4988        // Member joins at epoch 0, then goes OFFLINE.
4989        bed.swap_to(&member);
4990        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4991        assert_eq!(member_view.root_epoch, Epoch(0));
4992
4993        // While they sleep, the owner reshapes everything across three epochs.
4994        bed.swap_to(&owner);
4995        let stranger = Keys::generate();
4996        for epoch in 1..=3u64 {
4997            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
4998            assert_eq!(community.root_epoch, Epoch(epoch));
4999            send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
5000            send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
5001        }
5002        let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
5003        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5004        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
5005        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5006        send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
5007        set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
5008        let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
5009        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
5010
5011        // The member RETURNS: rekey+control follow to quiescence (the worker's
5012        // loop, driven explicitly). Bounded — convergence must be fast.
5013        bed.swap_to(&member);
5014        let session = SessionGuard::capture();
5015        let mut passes = 0;
5016        loop {
5017            passes += 1;
5018            assert!(passes <= 6, "catch-up must converge, not churn");
5019            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5020            let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
5021            assert!(!rekeyed.self_removed, "the member was never removed");
5022            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5023            let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
5024            if rekeyed.updated.is_none() && controlled.is_none() {
5025                break;
5026            }
5027        }
5028        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5029
5030        // Base + name converged.
5031        assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
5032        assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
5033        assert_eq!(caught_up.name, "Sleeper Reborn");
5034        // Channels: renamed set incl. the mid-sleep public + private ones.
5035        assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
5036        let m = caught_up.channel(&mods).expect("mods survived");
5037        let owner_mods = community.channel(&mods).unwrap();
5038        assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
5039        assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
5040        let v = caught_up.channel(&vault).expect("vault folded in");
5041        assert_eq!(v.key, community.channel(&vault).unwrap().key, "adopted the mid-sleep private channel's key");
5042        // Banlist survived the compactions.
5043        let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
5044        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
5045        assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
5046        // History reads across EVERY epoch (public via base-root archive, private
5047        // via the per-channel archive built during the walk).
5048        let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
5049        for epoch in 0..=3u64 {
5050            let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
5051            assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
5052        }
5053        let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
5054        for epoch in 0..=3u64 {
5055            let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
5056            assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
5057        }
5058        assert!(texts_in(&bed.relay, &caught_up, &vault).await.contains(&"epoch3: vault opened".to_string()));
5059        // And the member can still speak.
5060        send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
5061        bed.swap_to(&owner);
5062        assert!(
5063            texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
5064            "the caught-up member converses at the new epoch ({})",
5065            member.keys.public_key().to_bech32().unwrap()
5066        );
5067    }
5068
5069    /// Seal `n` messages onto a community's #general, one per second starting at
5070    /// `base_secs` (distinct wrap seconds so relay-side `until` paging engages).
5071    async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
5072        let general = community.channels[0].id;
5073        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
5074        for i in 0..n {
5075            let at = base_secs + i as u64;
5076            let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
5077            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
5078            relay.publish(&wrap, &community.relays).await.unwrap();
5079        }
5080    }
5081
5082    #[tokio::test]
5083    async fn the_history_walk_pages_past_a_multi_page_burst() {
5084        // A bot offline through 120 messages must catch ALL of them, not the
5085        // newest page — the v1 sync-gap class, closed by until-paging.
5086        let (_tmp, _guard, owner) = init_test_db();
5087        let relay = MemoryRelay::new();
5088        let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
5089        let general = community.channels[0].id;
5090        flood_general(&relay, &community, &owner, 120, 10_000).await;
5091
5092        let all = fetch_channel_history(&relay, &community, &general, 50, 8, |_| true).await.unwrap();
5093        assert_eq!(all.len(), 120, "the walk pages the whole burst");
5094        // Oldest→newest, no duplicates.
5095        let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
5096        assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
5097        assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
5098        let unique: std::collections::HashSet<&String> = contents.iter().collect();
5099        assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
5100
5101        // The single-page fetch stays a single page.
5102        let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
5103        assert_eq!(one.len(), 50, "fetch_channel is one newest page");
5104        assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
5105    }
5106
5107    #[tokio::test]
5108    async fn the_history_walk_stops_when_the_caller_is_caught_up() {
5109        let (_tmp, _guard, owner) = init_test_db();
5110        let relay = MemoryRelay::new();
5111        let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
5112        let general = community.channels[0].id;
5113        flood_general(&relay, &community, &owner, 120, 10_000).await;
5114
5115        // The caller says "I hold everything" after the first page — no deeper fetch.
5116        let mut pages = 0usize;
5117        let got = fetch_channel_history(&relay, &community, &general, 50, 8, |_| {
5118            pages += 1;
5119            false
5120        })
5121        .await
5122        .unwrap();
5123        assert_eq!(pages, 1, "the early stop is consulted once");
5124        assert_eq!(got.len(), 50, "only the newest page is fetched");
5125        assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
5126    }
5127
5128    #[tokio::test]
5129    async fn a_same_second_history_wall_terminates_instead_of_looping() {
5130        // 60 messages in ONE second with a 25-wrap page: a second-granular
5131        // `until` can never page past the wall — the walk must step over it
5132        // (bounded loss, logged) rather than spin.
5133        let (_tmp, _guard, owner) = init_test_db();
5134        let relay = MemoryRelay::new();
5135        let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
5136        let general = community.channels[0].id;
5137        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
5138        for i in 0..60usize {
5139            let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
5140            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
5141            relay.publish(&wrap, &community.relays).await.unwrap();
5142        }
5143        let got = fetch_channel_history(&relay, &community, &general, 25, 8, |_| true).await.unwrap();
5144        assert!(got.len() >= 25, "at least the relay page is read");
5145        assert!(got.len() <= 60, "sane bound");
5146        // Termination is the assertion: reaching here means the wall didn't loop.
5147    }
5148
5149    #[tokio::test]
5150    async fn a_grant_revoke_survives_a_withholding_relay() {
5151        // Floor persistence on the delegation plane: after the owner revokes an admin,
5152        // a relay serving only the OLD (still owner-signed) grant can't resurrect it.
5153        let (_tmp, _guard, owner) = init_test_db();
5154        let relay = MemoryRelay::new();
5155        let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
5156        let admin = Keys::generate();
5157        let rid = "d4".repeat(32);
5158        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
5159        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
5160        let session = SessionGuard::capture();
5161        follow_control(&relay, &community, &session).await.unwrap(); // seed floors incl. the grant at v1
5162        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke → grant floor v2
5163        follow_control(&relay, &community, &session).await.unwrap();
5164
5165        // A stale relay serves only the grant prefix (v1, the live grant).
5166        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
5167        let mut stale = community.clone();
5168        stale.relays = vec!["wss://stale".into()];
5169        let floors = load_floors(&community);
5170        let editions = fetch_control(&relay, &stale).await;
5171        let authority = fold_authority(&stale, &editions, &floors);
5172        assert!(
5173            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
5174            "the persisted grant floor refuses the rolled-back (re-granted) view"
5175        );
5176    }
5177
5178    /// Load the current-epoch floors for a community (test mirror of follow_control).
5179    fn load_floors(community: &CommunityV2) -> Floors {
5180        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5181        crate::db::community::get_all_edition_heads_full(&cid_hex)
5182            .unwrap_or_default()
5183            .into_iter()
5184            .filter(|(_, f)| f.0 == community.root_epoch.0)
5185            .map(|(e, f)| (e, (f.1, f.2, f.3)))
5186            .collect()
5187    }
5188
5189    /// Fetch + open every control edition at a community's control plane (test helper).
5190    async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
5191        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5192        let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
5193        relay
5194            .fetch(&q, &community.relays)
5195            .await
5196            .unwrap_or_default()
5197            .iter()
5198            .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
5199            .collect()
5200    }
5201
5202    #[tokio::test]
5203    async fn follow_control_is_a_noop_on_a_freshly_created_community() {
5204        let (_tmp, _guard, _owner) = init_test_db();
5205        let relay = MemoryRelay::new();
5206        let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
5207        let session = SessionGuard::capture();
5208        // Only the genesis editions exist; folding them reproduces the held view.
5209        assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
5210    }
5211
5212    #[tokio::test]
5213    async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
5214        let (_tmp, _guard, owner) = init_test_db();
5215        let relay = MemoryRelay::new();
5216        let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
5217        let new_id = ChannelId([0x5a; 32]);
5218        publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
5219
5220        let session = SessionGuard::capture();
5221        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
5222        assert_eq!(updated.channels.len(), 2);
5223        let added = updated.channel(&new_id).expect("the new channel folded in");
5224        assert_eq!(added.name, "announcements");
5225        assert!(!added.private);
5226        assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
5227
5228        // The new channel is now in the realtime author-set (it would be subscribed).
5229        let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
5230        let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
5231        assert!(authors.contains(&addr), "the added channel joins the live subscription");
5232
5233        // Persisted: a reload sees it too.
5234        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5235        assert!(reloaded.channel(&new_id).is_some());
5236    }
5237
5238    #[tokio::test]
5239    async fn follow_control_renames_the_community_and_an_existing_channel() {
5240        let (_tmp, _guard, owner) = init_test_db();
5241        let relay = MemoryRelay::new();
5242        let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
5243        let general = community.channels[0].id;
5244        // A v2 metadata edition renames the community; a v2 channel edition renames #general.
5245        publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
5246        publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
5247
5248        let session = SessionGuard::capture();
5249        let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
5250        assert_eq!(updated.name, "New Name");
5251        assert_eq!(updated.channel(&general).unwrap().name, "lobby");
5252        assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
5253    }
5254
5255    #[tokio::test]
5256    async fn follow_control_deletes_a_channel() {
5257        let (_tmp, _guard, owner) = init_test_db();
5258        let relay = MemoryRelay::new();
5259        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
5260        let extra = ChannelId([0x77; 32]);
5261        let session = SessionGuard::capture();
5262
5263        // The channel is first added and folded into the held view.
5264        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
5265        let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
5266        assert!(with_extra.channel(&extra).is_some());
5267
5268        // Then it's tombstoned — the delete (higher version) folds the held one back out.
5269        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
5270        let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
5271        assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
5272        assert_eq!(updated.channels.len(), 1, "only #general remains");
5273    }
5274
5275    /// Re-inject only the OLD prefix (every edition at/below `max_version`) of a
5276    /// community's control plane onto a second relay URL — the withholding-relay
5277    /// simulation: everything it serves is genuinely owner-signed, just stale.
5278    async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
5279        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5280        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
5281        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
5282        for w in &wraps {
5283            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
5284                if ed.version <= max_version {
5285                    relay.inject(w, &[stale_relay.to_string()]);
5286                }
5287            }
5288        }
5289    }
5290
5291    #[tokio::test]
5292    async fn a_withholding_relay_cannot_roll_back_a_rename() {
5293        // W2 persisted floor: after adopting the owner's v2 rename, a relay serving
5294        // only the (owner-signed) v1 genesis must not revert the held name.
5295        let (_tmp, _guard, owner) = init_test_db();
5296        let relay = MemoryRelay::new();
5297        let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
5298        publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
5299
5300        let session = SessionGuard::capture();
5301        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
5302        assert_eq!(updated.name, "Renamed");
5303
5304        // The stale relay holds only the genesis prefix; point the follow at it.
5305        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
5306        let mut stale_view = updated.clone();
5307        stale_view.relays = vec!["wss://stale".into()];
5308        assert!(
5309            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
5310            "a stale-only relay must not change the held view"
5311        );
5312        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5313        assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
5314    }
5315
5316    #[tokio::test]
5317    async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
5318        let (_tmp, _guard, owner) = init_test_db();
5319        let relay = MemoryRelay::new();
5320        let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
5321        let extra = ChannelId([0x44; 32]);
5322        let session = SessionGuard::capture();
5323
5324        // A same-content metadata edit: no visible change (None), but the floor must
5325        // still advance to v2 (so the genesis metadata can't re-present below).
5326        publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
5327        assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
5328
5329        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
5330        let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
5331        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
5332        let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
5333        assert!(pruned.channel(&extra).is_none());
5334
5335        // The stale relay serves the add (v1) but withholds the delete (v2).
5336        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
5337        let mut stale_view = pruned.clone();
5338        stale_view.relays = vec!["wss://stale".into()];
5339        assert!(
5340            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
5341            "the withheld delete must not resurrect the channel"
5342        );
5343        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5344        assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
5345    }
5346
5347    #[tokio::test]
5348    async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
5349        // The Armada-convergence carve-out: a Refounding compacts the chain and
5350        // re-wraps a detached head at the NEW epoch's control plane. The old epoch's
5351        // floor must not block it — epoch-filtering makes the entity bootstrap.
5352        let (_tmp, _guard, owner) = init_test_db();
5353        let relay = MemoryRelay::new();
5354        let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
5355        let session = SessionGuard::capture();
5356        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
5357        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
5358        assert_eq!(updated.name, "Edited");
5359
5360        // Refounding lands (epoch bump saved by the rekey path); the compacted head
5361        // arrives DETACHED (high version, no prev) on the new epoch's plane.
5362        let mut refounded = updated.clone();
5363        refounded.root_epoch = crate::community::Epoch(1);
5364        crate::db::community::save_community_v2(&refounded).unwrap();
5365        publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
5366
5367        let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
5368        assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
5369        // The persisted floor is stamped with the epoch the FOLD ran under.
5370        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5371        let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
5372        assert!(
5373            heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
5374            "the adopted head carries the fold's epoch + version"
5375        );
5376    }
5377
5378    #[tokio::test]
5379    async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
5380        // Two owner-signed editions at the SAME version (publish retry / two owner
5381        // devices): every client must land on the lower-inner-id winner. A hash-strict
5382        // floor would wedge here forever while Armada converges — the floor must
5383        // CONVERGE instead (the v1 decide() rule).
5384        let (_tmp, _guard, owner) = init_test_db();
5385        let relay = MemoryRelay::new();
5386        let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
5387        let session = SessionGuard::capture();
5388        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5389        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
5390
5391        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
5392        let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
5393        assert_eq!(ours.name, "Ours");
5394
5395        // Our committed v2 edition's tiebreak id.
5396        let our_inner = {
5397            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
5398            let wraps = relay.fetch(&q, &community.relays).await.unwrap();
5399            wraps
5400                .iter()
5401                .find_map(|w| {
5402                    control::open_control_edition(w, &group)
5403                        .ok()
5404                        .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
5405                        .map(|(ed, _)| ed.inner_id)
5406                })
5407                .unwrap()
5408        };
5409
5410        // Craft the concurrent fork so it WINS the deterministic tiebreak (vary the
5411        // authored timestamp until its inner id is lower).
5412        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
5413        let content = serde_json::to_string(&meta).unwrap();
5414        let mut ts = 2_000u64;
5415        let fork_wrap = loop {
5416            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
5417            let inner = rumor.id.unwrap().to_bytes();
5418            if inner < our_inner {
5419                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
5420            }
5421            ts += 1;
5422        };
5423        relay.publish(&fork_wrap, &community.relays).await.unwrap();
5424
5425        let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
5426        assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
5427        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5428        let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
5429        assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
5430    }
5431
5432    #[tokio::test]
5433    async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
5434        // v2 chains to the floor; v4 arrives but its v3 link is withheld. The
5435        // chain-verified prefix (v2) applies NOW — refuse-downgrade holds for it —
5436        // while the detached v4 waits. When v3 lands, the chain heals to v4.
5437        let (_tmp, _guard, owner) = init_test_db();
5438        let relay = MemoryRelay::new();
5439        let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
5440        let session = SessionGuard::capture();
5441        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5442
5443        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
5444        let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
5445
5446        // Craft v3 (held back) and v4 (published, chained to the withheld v3).
5447        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
5448        let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
5449        let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
5450        let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
5451        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
5452        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
5453        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
5454        relay.publish(&w4, &community.relays).await.unwrap();
5455
5456        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
5457        assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
5458
5459        relay.publish(&w3, &community.relays).await.unwrap();
5460        let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
5461        assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
5462    }
5463
5464    #[tokio::test]
5465    async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
5466        // The held floor is v2; the owner publishes v3, then a flood of foreign junk
5467        // wraps fills the newest window, then v4. Page 1 sees only v4 (detached →
5468        // gapped); paging older must recover v3 (and the floor link) and heal to v4.
5469        let (_tmp, _guard, owner) = init_test_db();
5470        let relay = MemoryRelay::new();
5471        let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
5472        let session = SessionGuard::capture();
5473        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5474
5475        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
5476        let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
5477        publish_community_meta(&relay, &base, &owner, "Three", 3).await; // ts 1_000 (old)
5478        let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
5479
5480        // Rogue flood occupying the newest window (sealed to the control plane, but
5481        // non-owner — the authority gate drops them; they only crowd the page).
5482        let rogue = Keys::generate();
5483        for i in 0..(FOLLOW_PAGE as u64 - 1) {
5484            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
5485            let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
5486            relay.publish(&w, &community.relays).await.unwrap();
5487        }
5488        // v4 chained to the real v3 (crafted directly: the flood also blinds the
5489        // helper's own newest-window head lookup), timestamped newest of all.
5490        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
5491        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
5492        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
5493        relay.publish(&w4, &community.relays).await.unwrap();
5494
5495        let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
5496        assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
5497    }
5498
5499    #[tokio::test]
5500    async fn a_follow_after_delete_does_not_resurrect_the_community() {
5501        // A leave/delete racing an in-flight follow: the follow must not re-insert
5502        // the community row or floor rows past delete_community's wipe.
5503        let (_tmp, _guard, owner) = init_test_db();
5504        let relay = MemoryRelay::new();
5505        let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
5506        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
5507        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5508        crate::db::community::delete_community(&cid_hex).unwrap();
5509
5510        let session = SessionGuard::capture();
5511        assert!(
5512            follow_control(&relay, &community, &session).await.unwrap().is_none(),
5513            "a follow racing a delete is a no-op"
5514        );
5515        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
5516        assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
5517    }
5518
5519    #[tokio::test]
5520    async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
5521        // The rekey sibling of the follow_control guard: an owner rotation adopted
5522        // mid-race must not upsert the community row back after a leave/delete.
5523        let (_tmp, _guard, owner) = init_test_db();
5524        let relay = MemoryRelay::new();
5525        let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
5526        let new_root = [0xB2; 32];
5527        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
5528        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5529        crate::db::community::delete_community(&cid_hex).unwrap();
5530
5531        let session = SessionGuard::capture();
5532        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
5533        assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
5534        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
5535    }
5536
5537    #[tokio::test]
5538    async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
5539        // {v1, v3} on the relays with v2 lost at publish time (a rate-limiting relay
5540        // that still ACKed): the genesis anchors, so an anchored-prefix-first fold
5541        // would take v1 and SEED the joiner's floor there — pinning them below the
5542        // head Armada shows, forever. A joiner (floor 0) must bootstrap v3.
5543        let (bed, owner, member) = TestBed::new();
5544        bed.swap_to(&owner);
5545        let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
5546        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5547        let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
5548
5549        // v2 is crafted but NEVER published; v3 chains to it and is published.
5550        let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
5551        let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
5552        let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
5553        let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
5554        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
5555        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);
5556        let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
5557        bed.relay.publish(&w3, &community.relays).await.unwrap();
5558
5559        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5560        let bundle_json = serde_json::to_string(&bundle).unwrap();
5561        bed.swap_to(&member);
5562        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5563        assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
5564        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
5565        let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
5566        assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
5567    }
5568
5569    #[tokio::test]
5570    async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
5571        // The refusal half of fork convergence: a relay withholding OUR committed
5572        // floor edition while serving only a same-version fork with a HIGHER inner
5573        // id must be treated as withholding — held state and floor unchanged.
5574        let (_tmp, _guard, owner) = init_test_db();
5575        let relay = MemoryRelay::new();
5576        let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
5577        let session = SessionGuard::capture();
5578        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5579        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
5580
5581        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
5582        let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
5583        assert_eq!(ours.name, "Ours");
5584        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5585        let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
5586        let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
5587
5588        // Grind the fork to LOSE the tiebreak (higher inner id), then serve it —
5589        // with the genesis but WITHOUT our v2 — from a withholding relay.
5590        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
5591        let content = serde_json::to_string(&meta).unwrap();
5592        let mut ts = 5_000u64;
5593        let fork_wrap = loop {
5594            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
5595            if rumor.id.unwrap().to_bytes() > our_inner {
5596                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
5597            }
5598            ts += 1;
5599        };
5600        inject_stale_prefix(&relay, &community, 1, "wss://stale").await; // genesis only
5601        relay.inject(&fork_wrap, &["wss://stale".to_string()]);
5602        let mut stale_view = ours.clone();
5603        stale_view.relays = vec!["wss://stale".into()];
5604
5605        assert!(
5606            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
5607            "a losing fork served without our floor edition changes nothing"
5608        );
5609        let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
5610        assert_eq!(held_after, held_before, "the floor row is untouched");
5611        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5612        assert_eq!(held.name, "Ours", "the held state is untouched");
5613    }
5614
5615    #[tokio::test]
5616    async fn follow_control_ignores_a_non_owner_edition() {
5617        // A member holds the community_root, so they CAN seal a control edition —
5618        // but they aren't the owner, so the authority gate drops it (first cut:
5619        // owner-only). The rogue channel must never appear.
5620        let (_tmp, _guard, _owner) = init_test_db();
5621        let relay = MemoryRelay::new();
5622        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
5623        let rogue = Keys::generate();
5624        let rogue_id = ChannelId([0x99; 32]);
5625        publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
5626
5627        let session = SessionGuard::capture();
5628        assert!(
5629            follow_control(&relay, &community, &session).await.unwrap().is_none(),
5630            "a non-owner control edition is not folded"
5631        );
5632    }
5633
5634    #[tokio::test]
5635    async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
5636        // A Private channel's key rides the rekey plane, not the control edition —
5637        // control-follow records it KEYLESS (epoch 0, the rekey-scan cursor), and
5638        // every read/send path refuses it until the key lands (never the root plane).
5639        let (_tmp, _guard, owner) = init_test_db();
5640        let relay = MemoryRelay::new();
5641        let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
5642        let priv_id = ChannelId([0x33; 32]);
5643        publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
5644
5645        let session = SessionGuard::capture();
5646        let updated = follow_control(&relay, &community, &session)
5647            .await
5648            .unwrap()
5649            .expect("the keyless record is a change");
5650        let ch = updated.channel(&priv_id).expect("the private channel is recorded");
5651        assert!(ch.private && ch.key.is_none(), "recorded keyless");
5652        assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
5653        assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
5654        assert!(
5655            fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
5656            "a keyless fetch returns empty (and never queries the root plane)"
5657        );
5658        assert!(
5659            send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
5660            "a keyless send refuses"
5661        );
5662        // The keyless record round-trips (the stored placeholder never surfaces
5663        // as a real key).
5664        let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
5665        let rch = reloaded.channel(&priv_id).unwrap();
5666        assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
5667        // And a bundle minted while keyless never carries the placeholder.
5668        let bundle = bundle_of(&reloaded, None, None, None);
5669        assert!(
5670            !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
5671            "an ungrantable keyless channel stays out of invite bundles"
5672        );
5673    }
5674
5675    // ── Live rekey-follow ────────────────────────────────────────────────────
5676
5677    /// Publish an owner-grammar base rotation (Refounding) delivering `new_root`
5678    /// to each recipient. `rotator` is the seal signer (owner for a legit rotation,
5679    /// a stranger for the authority test); `prev_key` is the root it claims to
5680    /// extend (mismatch → a fork).
5681    async fn publish_base_rotation(
5682        relay: &MemoryRelay,
5683        community: &CommunityV2,
5684        rotator: &Keys,
5685        recipients: &[PublicKey],
5686        new_root: &[u8; 32],
5687        prev_key: &[u8; 32],
5688    ) {
5689        let new_epoch = Epoch(community.root_epoch.0 + 1);
5690        let prev_epoch = community.root_epoch;
5691        let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
5692        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
5693        let blobs: Vec<_> = recipients
5694            .iter()
5695            .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
5696            .collect();
5697        let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000).unwrap();
5698        for e in &events {
5699            relay.publish(e, &community.relays).await.unwrap();
5700        }
5701    }
5702
5703    /// Attach a Private channel (key + epoch) to a held community and persist it.
5704    fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
5705        community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch });
5706        crate::db::community::save_community_v2(community).unwrap();
5707    }
5708
5709    #[tokio::test]
5710    async fn follow_rekeys_is_a_noop_without_rotations() {
5711        let (_tmp, _guard, _owner) = init_test_db();
5712        let relay = MemoryRelay::new();
5713        let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
5714        let session = SessionGuard::capture();
5715        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
5716        assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
5717    }
5718
5719    #[tokio::test]
5720    async fn follow_rekeys_adopts_an_owner_base_rotation() {
5721        let (_tmp, _guard, owner) = init_test_db();
5722        let relay = MemoryRelay::new();
5723        let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
5724        let new_root = [0xB1; 32];
5725        // Owner rotates the base to epoch 1, delivering the new root to me.
5726        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
5727
5728        let session = SessionGuard::capture();
5729        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
5730        assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
5731        assert_eq!(updated.community_root, new_root, "adopted the fresh root");
5732        // The public channel now reads under the NEW root/epoch (its address moved).
5733        let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
5734        let general = updated.channels[0].id;
5735        let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
5736        assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
5737    }
5738
5739    #[tokio::test]
5740    async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
5741        let (_tmp, _guard, owner) = init_test_db();
5742        let relay = MemoryRelay::new();
5743        let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
5744        let priv_id = ChannelId([0x33; 32]);
5745        add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
5746
5747        // Owner rotates the private channel to epoch 1 with a fresh key, delivered to me.
5748        let new_key = [0x55; 32];
5749        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
5750        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
5751        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();
5752        let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000).unwrap();
5753        for e in &events {
5754            relay.publish(e, &community.relays).await.unwrap();
5755        }
5756
5757        let session = SessionGuard::capture();
5758        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
5759        let ch = updated.channel(&priv_id).unwrap();
5760        assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
5761        assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
5762        assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
5763    }
5764
5765    #[tokio::test]
5766    async fn follow_rekeys_ignores_a_non_owner_rotation() {
5767        // A member holds the community_root, so they can derive the rekey group key
5768        // and mint a rotation — but they aren't the owner, so it's not adopted.
5769        let (_tmp, _guard, _owner) = init_test_db();
5770        let relay = MemoryRelay::new();
5771        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
5772        let rogue = Keys::generate();
5773        publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
5774
5775        let session = SessionGuard::capture();
5776        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
5777        assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
5778    }
5779
5780    #[tokio::test]
5781    async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
5782        // A rotation whose prevcommit doesn't match the key I hold is a fork, not an
5783        // extension — never adopted (would splice me onto an unrelated chain).
5784        let (_tmp, _guard, owner) = init_test_db();
5785        let relay = MemoryRelay::new();
5786        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
5787        // prev_key ≠ the real community_root → the continuity check reads Fork.
5788        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
5789
5790        let session = SessionGuard::capture();
5791        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
5792        assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
5793    }
5794
5795    #[tokio::test]
5796    async fn follow_rekeys_holds_on_an_incomplete_rotation() {
5797        // A 2-chunk rotation with only chunk 1 present can never conclude — not an
5798        // adoption, and crucially NOT a removal (a missing chunk might carry my blob).
5799        let (_tmp, _guard, owner) = init_test_db();
5800        let relay = MemoryRelay::new();
5801        let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
5802        let new_epoch = Epoch(1);
5803        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
5804        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
5805        // Chunk 1 of a declared 2, carrying someone else's blob (not mine).
5806        let other = Keys::generate();
5807        let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
5808        let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000).unwrap();
5809        let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
5810        relay.publish(&wrap, &community.relays).await.unwrap();
5811
5812        let session = SessionGuard::capture();
5813        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
5814        assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
5815    }
5816
5817    #[tokio::test]
5818    async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
5819        // Realistic two-actor removal: the owner Refounds the base and delivers the
5820        // new root to a THIRD party, not the member — a complete rotation with no
5821        // blob for the member is a removal.
5822        let (bed, owner, member) = TestBed::new();
5823        bed.swap_to(&owner);
5824        let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
5825        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
5826
5827        bed.swap_to(&member);
5828        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
5829        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
5830
5831        // Owner rotates, delivering only to a stranger (the member is dropped).
5832        bed.swap_to(&owner);
5833        let stranger = Keys::generate();
5834        publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
5835
5836        // The member's follow concludes removal (a complete rotation without their blob).
5837        bed.swap_to(&member);
5838        let session = SessionGuard::capture();
5839        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
5840        assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
5841        assert!(follow.updated.is_none(), "a removed member adopts nothing");
5842    }
5843
5844    #[tokio::test]
5845    async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
5846        // PROTO-B2 regression: a Refounding's channel rekeys ride the PRIOR root
5847        // (CORD-06 §3). A follower who adopted the BASE first (the live window:
5848        // the base crate landed and was walked before the channel crates) must
5849        // still find them — the lookup fans across the archived roots, not just
5850        // the current one.
5851        let (_tmp, _guard, owner) = init_test_db();
5852        let relay = MemoryRelay::new();
5853        let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
5854        let root0 = community.community_root;
5855        let priv_id = ChannelId([0x33; 32]);
5856        let key1 = [0x44; 32];
5857        add_private_channel(&mut community, priv_id, key1, Epoch(1));
5858
5859        // The refounder's channel rekey (1 → 2), sealed + addressed under the PRIOR
5860        // root (root0), delivering the fresh key to me.
5861        let key2 = [0x55; 32];
5862        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
5863        let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
5864        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();
5865        for e in rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &prev_commit, &[blob], 2_000).unwrap() {
5866            relay.publish(&e, &community.relays).await.unwrap();
5867        }
5868
5869        // Simulate the base having ALREADY advanced (the stranding order): the head
5870        // moved to a fresh root while root0 sits in the epoch-key archive (where
5871        // genesis put it).
5872        community.community_root = [0xB7; 32];
5873        community.root_epoch = Epoch(1);
5874        crate::db::community::save_community_v2(&community).unwrap();
5875
5876        let session = SessionGuard::capture();
5877        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
5878        let ch = updated.channel(&priv_id).unwrap();
5879        assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
5880        assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
5881    }
5882
5883    #[tokio::test]
5884    async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
5885        // A keyless private channel (announced by vsk-2, key not yet held) has no
5886        // chain, so its epoch is a scan cursor: a complete rotation that excludes
5887        // us advances the cursor (never a removal — we were never in); a later
5888        // rotation that includes us is the entry point.
5889        let (_tmp, _guard, owner) = init_test_db();
5890        let relay = MemoryRelay::new();
5891        let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
5892        let priv_id = ChannelId([0x66; 32]);
5893        community.channels.push(ChannelV2 { id: priv_id, name: "vault".into(), private: true, key: None, epoch: Epoch(0) });
5894        crate::db::community::save_community_v2(&community).unwrap();
5895        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5896        assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
5897
5898        // Epoch 1: the creation delivery went to a stranger only (pre-dates us).
5899        let stranger = Keys::generate();
5900        let key1 = [0x71; 32];
5901        let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
5902        let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
5903        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();
5904        for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000).unwrap() {
5905            relay.publish(&e, &community.relays).await.unwrap();
5906        }
5907        // Epoch 2: a later rotation includes ME (e.g. a removal-forced re-mint whose
5908        // recipient set is the CURRENT members).
5909        let key2 = [0x72; 32];
5910        let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
5911        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
5912        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();
5913        for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100).unwrap() {
5914            relay.publish(&e, &community.relays).await.unwrap();
5915        }
5916
5917        // ONE follow: the cursor walks 0→1 (excluded, still keyless) and 1→2 (my
5918        // blob — adopt), because each real step re-loops.
5919        let session = SessionGuard::capture();
5920        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
5921        let ch = updated.channel(&priv_id).unwrap();
5922        assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
5923        assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
5924    }
5925
5926    #[tokio::test]
5927    async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
5928        // CORD-06 §Authority: a CHANNEL rekey is honored from the owner or a
5929        // MANAGE_CHANNELS holder under the persisted roster — so an admin-run
5930        // rotation keys members up; a mere keyholder's forgery never does.
5931        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
5932        let (_tmp, _guard, _owner) = init_test_db();
5933        let relay = MemoryRelay::new();
5934        let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
5935        let priv_id = ChannelId([0x88; 32]);
5936        let key1 = [0x91; 32];
5937        add_private_channel(&mut community, priv_id, key1, Epoch(1));
5938
5939        // Persist a roster granting `admin` the Admin role (MANAGE_CHANNELS ⊂ ADMIN_ALL).
5940        let admin = Keys::generate();
5941        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5942        let role = Role::admin("aa".repeat(32));
5943        let roster = CommunityRoles {
5944            roles: vec![role.clone()],
5945            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
5946        };
5947        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
5948
5949        // The ADMIN rotates the channel 1 → 2, delivering to me: adopted.
5950        let key2 = [0x92; 32];
5951        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
5952        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
5953        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
5954        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
5955        for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000).unwrap() {
5956            relay.publish(&e, &community.relays).await.unwrap();
5957        }
5958        let session = SessionGuard::capture();
5959        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
5960        assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
5961
5962        // A STRANGER (keyholder, no roster standing) rotates 2 → 3: refused.
5963        let rogue = Keys::generate();
5964        let key3 = [0x93; 32];
5965        let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
5966        let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
5967        let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
5968        for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100).unwrap() {
5969            relay.publish(&e, &updated.relays).await.unwrap();
5970        }
5971        let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
5972        assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
5973    }
5974
5975    #[tokio::test]
5976    async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
5977        // CORD-06 §Authority: the Rotator must strictly OUTRANK every removed
5978        // target. An equal-rank bit-holder's complete rotation that skips my blob
5979        // must read Stay (my record survives); the OWNER's reads Removed. Needs a
5980        // two-account bed: the follower must be a NON-owner admin.
5981        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
5982        let (bed, owner, member) = TestBed::new();
5983        bed.swap_to(&owner);
5984        let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
5985
5986        // The MEMBER's device: holds the community + the private channel, with a
5987        // persisted roster granting the member AND a peer the same Admin role.
5988        bed.swap_to(&member);
5989        let mut held = community.clone();
5990        let priv_id = ChannelId([0xAB; 32]);
5991        let key1 = [0xA1; 32];
5992        add_private_channel(&mut held, priv_id, key1, Epoch(1));
5993        let peer = Keys::generate();
5994        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5995        let role = Role::admin("bb".repeat(32));
5996        let roster = CommunityRoles {
5997            roles: vec![role.clone()],
5998            grants: vec![
5999                MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
6000                MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
6001            ],
6002        };
6003        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
6004
6005        // The equal-rank PEER rotates 1 → 2 delivering only to themselves.
6006        let key2 = [0xA2; 32];
6007        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6008        let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
6009        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();
6010        for e in rekey::build_rekey_chunks_local(&peer, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[pb], 2_000).unwrap() {
6011            bed.relay.publish(&e, &held.relays).await.unwrap();
6012        }
6013        let session = SessionGuard::capture();
6014        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
6015        assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
6016        let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
6017        assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
6018
6019        // The OWNER's rotation excluding me IS a removal (owner outranks everyone).
6020        let key3 = [0xA3; 32];
6021        let stranger = Keys::generate();
6022        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();
6023        for e in rekey::build_rekey_chunks_local(&owner.keys, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[ob], 2_100).unwrap() {
6024            bed.relay.publish(&e, &held.relays).await.unwrap();
6025        }
6026        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
6027        let updated = follow.updated.expect("the owner's removal folds");
6028        assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
6029    }
6030
6031    #[tokio::test]
6032    async fn converting_a_public_channel_to_private_is_refused() {
6033        // The conversion (CORD-03 §2) is a key rotation this build doesn't mint yet:
6034        // the producer refuses the flag flip, so no reader is left unkeyable.
6035        let (_tmp, _guard, _owner) = init_test_db();
6036        let relay = MemoryRelay::new();
6037        let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
6038        let general = community.channels[0].id;
6039        let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
6040        let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
6041        assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
6042        // A rename of the same public channel still works.
6043        let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
6044        edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
6045    }
6046
6047    /// Publish a 13302 (signed by `me`) carrying a leave tombstone for `cid_hex` at
6048    /// `removed_at` — simulating a sibling device having left that community.
6049    async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
6050        let doc = super::super::list::CommunityList {
6051            entries: vec![],
6052            tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
6053            extra: Default::default(),
6054        };
6055        let event = super::super::list::build_list_event(me, &doc).unwrap();
6056        relay.publish(&event, relays).await.unwrap();
6057    }
6058
6059    #[tokio::test]
6060    async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
6061        // W1 (send side): a sibling device left X (a remote tombstone). Joining a
6062        // DIFFERENT community must not re-add X to the 13302 with added_at=now,
6063        // which would silently undo the leave everywhere.
6064        let (_tmp, _guard, me) = init_test_db();
6065        let relay = MemoryRelay::new();
6066        let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
6067        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6068
6069        // A sibling leaves X: a remote tombstone strictly newer than X's add.
6070        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
6071
6072        // Now join a different community Y → republish(just_joined = Y).
6073        let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
6074        republish_community_list(&relay, Some(y.id())).await.unwrap();
6075
6076        // X must still read as LEFT in the published list; Y must be live.
6077        let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
6078        assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
6079        assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
6080    }
6081
6082    #[tokio::test]
6083    async fn sync_tears_down_a_community_a_sibling_left() {
6084        // W1 (receive side): a community still held locally that the synced 13302
6085        // shows tombstoned-and-not-live is torn down, so a leave propagates.
6086        let (_tmp, _guard, me) = init_test_db();
6087        let relay = MemoryRelay::new();
6088        let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
6089        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6090        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
6091
6092        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
6093        sync_community_list(&relay, &x.relays).await.unwrap();
6094        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
6095    }
6096
6097    #[tokio::test]
6098    async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
6099        // The re-join case must NOT be torn down: a fresh join re-adds live (beating
6100        // the tombstone), so a later sync keeps it.
6101        let (_tmp, _guard, me) = init_test_db();
6102        let relay = MemoryRelay::new();
6103        let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
6104        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6105        // A stale tombstone from a prior leave (OLDER than the current hold's re-add).
6106        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
6107        // Re-record the membership (a re-join) → live entry at now >> 1.
6108        republish_community_list(&relay, Some(x.id())).await.unwrap();
6109        sync_community_list(&relay, &x.relays).await.unwrap();
6110        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
6111    }
6112
6113    #[tokio::test]
6114    async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
6115        // W2: a transient fetch failure during republish must not drive the
6116        // replaceable-event write (which would drop other entries / regress seeds).
6117        let (_tmp, _guard, _me) = init_test_db();
6118        let good = MemoryRelay::new();
6119        let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
6120        assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
6121
6122        // A transport whose fetch always errors: republish must bail, publishing nothing.
6123        struct FetchErrors;
6124        #[async_trait::async_trait]
6125        impl Transport for FetchErrors {
6126            async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6127                panic!("republish must NOT publish when the remote fetch failed");
6128            }
6129            async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6130                Ok(())
6131            }
6132            async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
6133                Err("relay unreachable".to_string())
6134            }
6135        }
6136        // Returns Ok (best-effort) but must not have published (the panic guards it).
6137        republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
6138    }
6139
6140    #[tokio::test]
6141    async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
6142        // B1 regression: refound_community's recipient set = memberlist. A member
6143        // the owner GRANTED a role to but who never left a (surviving) Guestbook
6144        // Join — a lurking admin, or one whose Join aged out of the window — must
6145        // still be a rekey recipient, or the Refounding SEVERS them. The folded
6146        // roster's granted members are the consensus-complete backstop.
6147        let (_tmp, _guard, owner) = init_test_db();
6148        let relay = MemoryRelay::new();
6149        let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
6150
6151        // A lurker gets an admin grant but publishes NO Guestbook Join and no chat.
6152        let lurker = Keys::generate();
6153        let rid = "b1".repeat(32);
6154        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
6155        publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
6156
6157        // memberlist includes the lurker purely via the roster backstop.
6158        let members = memberlist(&relay, &community).await.unwrap();
6159        assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
6160
6161        // A banned grantee whose grant wasn't stripped is NOT re-admitted.
6162        let banned_grantee = Keys::generate();
6163        publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
6164        set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
6165        let members = memberlist(&relay, &community).await.unwrap();
6166        assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
6167        assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
6168
6169        // And the Refounding actually delivers the new root to the lurker.
6170        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
6171        assert_eq!(refounded.root_epoch, Epoch(1));
6172        let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
6173        let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
6174        let rotations = rekey::collect_rotations(&chunks);
6175        let lurker_x = lurker.public_key().to_bytes();
6176        let delivered = rotations.iter().any(|r| {
6177            rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
6178        });
6179        assert!(delivered, "the Refounding delivered the new root to the granted lurker");
6180    }
6181
6182    #[tokio::test]
6183    async fn the_memberlist_pages_past_a_guestbook_flood() {
6184        // The roleless-member half of B1: >500 Guestbook events must not evict an
6185        // honest member's Join from the counted set (an insider can flood throwaway
6186        // Joins to force exactly this). The pager sees them all.
6187        let (_tmp, _guard, owner) = init_test_db();
6188        let relay = MemoryRelay::new();
6189        let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
6190        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
6191
6192        // An honest member's Join (oldest), then 600 throwaway Joins on top.
6193        let honest = Keys::generate();
6194        let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
6195        let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
6196        relay.publish(&w, &community.relays).await.unwrap();
6197        for i in 0..600u64 {
6198            let throwaway = Keys::generate();
6199            let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
6200            let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
6201            relay.publish(&w, &community.relays).await.unwrap();
6202        }
6203
6204        let members = memberlist(&relay, &community).await.unwrap();
6205        assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
6206    }
6207
6208    #[tokio::test]
6209    async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
6210        // An insider floods the next-epoch rekey address (community_root-derived,
6211        // so any member can seal there) with >200 junk 3303s to push the owner's
6212        // genuine rotation out of a single fetch window. The paginated fetch must
6213        // still recover it and adopt.
6214        let (_tmp, _guard, owner) = init_test_db();
6215        let relay = MemoryRelay::new();
6216        let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
6217        let new_root = [0xD9; 32];
6218        let new_epoch = Epoch(1);
6219        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
6220
6221        // The GENUINE owner rotation lands first (oldest).
6222        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
6223
6224        // Then a member floods 260 well-formed-but-unauthorized junk chunks ON TOP
6225        // (newer), burying the genuine one past the 200 newest.
6226        let rogue = Keys::generate();
6227        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
6228        for i in 0..260u64 {
6229            let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
6230            let rumor = rekey::build_rekey_rumor(rogue.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 1, 3_000 + i).unwrap();
6231            let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
6232            relay.publish(&wrap, &community.relays).await.unwrap();
6233        }
6234
6235        let session = SessionGuard::capture();
6236        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
6237        assert_eq!(updated.root_epoch, Epoch(1));
6238        assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
6239    }
6240
6241    #[tokio::test]
6242    async fn a_swap_during_create_private_channel_aborts_without_a_write() {
6243        // create_private_channel straddles a memberlist fetch (seconds long) then
6244        // whole-row-saves. A swap in that window must abort — never mint a channel
6245        // into the swapped-in account, and never leave a half-published key crate
6246        // adopted locally.
6247        let (bed, owner, _member) = TestBed::new();
6248        bed.swap_to(&owner);
6249        let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
6250        let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
6251
6252        // The memberlist fetch inside create bumps the generation mid-flight.
6253        let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
6254        let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
6255        assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
6256        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6257        assert_eq!(after.channels.len(), before, "no channel row was written");
6258        assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
6259    }
6260
6261    #[tokio::test]
6262    async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
6263        // CORD-06 §Failure-and-races: two DISTINCT authorized rotators mint the
6264        // same channel epoch concurrently (reachable — both hold MANAGE_CHANNELS).
6265        // Every follower must converge on the SAME key (the lexicographically
6266        // lowest), so the community never permanently forks. (Retaining the losing
6267        // fork's key for its race-window messages needs a multi-key-per-epoch
6268        // archive — a deferred refinement shared with v1; convergence, the
6269        // security-critical property, is what this pins.)
6270        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6271        let (_tmp, _guard, owner) = init_test_db();
6272        let relay = MemoryRelay::new();
6273        let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
6274        let priv_id = ChannelId([0xC0; 32]);
6275        let key1 = [0xC1; 32];
6276        add_private_channel(&mut community, priv_id, key1, Epoch(1));
6277
6278        // Two admins (a, b) both hold the Admin role; I hold the channel key.
6279        let (a, b) = (Keys::generate(), Keys::generate());
6280        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6281        let role = Role::admin("ce".repeat(32));
6282        let roster = CommunityRoles {
6283            roles: vec![role.clone()],
6284            grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
6285        };
6286        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
6287
6288        // Both rotate 1 → 2, each delivering their OWN fresh key to me, off the
6289        // same prevcommit — a genuine same-epoch fork.
6290        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
6291        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6292        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
6293        let key_a = [0x0A; 32];
6294        let key_b = [0xFB; 32]; // higher — a's must win regardless of publish order
6295        for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
6296            let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
6297            for e in rekey::build_rekey_chunks_local(signer, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000).unwrap() {
6298                relay.publish(&e, &community.relays).await.unwrap();
6299            }
6300        }
6301
6302        let session = SessionGuard::capture();
6303        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
6304        let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
6305        assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
6306
6307        // A SECOND follower (fresh, holding the same epoch-1 key) converges identically.
6308        let mut peer = community.clone();
6309        if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
6310            c.key = Some(key1);
6311            c.epoch = Epoch(1);
6312        }
6313        // Re-run the same fold from the peer's identical starting point → same winner.
6314        let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
6315        assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
6316    }
6317
6318    #[tokio::test]
6319    async fn create_private_channel_refuses_a_member_without_manage_channels() {
6320        // The local mirror of the reader's gate: an unauthorized member is refused
6321        // BEFORE any publish (no floor pollution, no orphan key crate).
6322        let (bed, owner, member) = TestBed::new();
6323        bed.swap_to(&owner);
6324        let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
6325        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6326
6327        bed.swap_to(&member);
6328        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6329        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6330        let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
6331        assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
6332        let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
6333        assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
6334    }
6335
6336    // ── Audit regressions ────────────────────────────────────────────────────
6337
6338    #[tokio::test]
6339    async fn accept_rejects_a_bundle_with_a_forged_community_root() {
6340        // The eclipse: community_id commits only to (owner, salt) — both semi-public
6341        // — so a forged invite pairs the REAL triple with an attacker root, and every
6342        // plane derives from it. The join-time owner-genesis check must refuse.
6343        let (bed, owner, member) = TestBed::new();
6344        bed.swap_to(&owner);
6345        let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
6346
6347        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
6348        let mut forged = bundle_of(&community, None, None, None);
6349        forged.community_root = fake.clone();
6350        for ch in &mut forged.channels {
6351            ch.key = fake.clone();
6352        }
6353        let attacker = Keys::generate();
6354        let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
6355        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
6356
6357        bed.swap_to(&member);
6358        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6359        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6360        assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
6361        assert!(
6362            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6363            "a rejected join persists nothing"
6364        );
6365    }
6366
6367    #[tokio::test]
6368    async fn follow_control_heals_a_bundle_misclassified_public_channel() {
6369        // A bundle can set a PUBLIC channel's grant key to the attacker's, so the
6370        // joiner addresses it at a plane only the attacker reads. The owner's genuine
6371        // public:false edition must override it on follow.
6372        let (_tmp, _guard, _owner) = init_test_db();
6373        let relay = MemoryRelay::new();
6374        let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
6375        let general = community.channels[0].id;
6376        let mut poisoned = community.clone();
6377        poisoned.channels[0].private = true;
6378        poisoned.channels[0].key = Some([0x66; 32]);
6379        crate::db::community::save_community_v2(&poisoned).unwrap();
6380
6381        let session = SessionGuard::capture();
6382        let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
6383        let ch = healed.channel(&general).unwrap();
6384        assert!(!ch.private, "the owner's public declaration overrides the bundle");
6385        assert_eq!(ch.key, None, "a healed public channel derives from the root");
6386    }
6387
6388    #[tokio::test]
6389    async fn a_deleted_channel_does_not_resurrect_on_reload() {
6390        // save_community_v2 must prune orphan channel rows, or a control-follow delete
6391        // reappears (with a stale key) on the next reload.
6392        let (_tmp, _guard, owner) = init_test_db();
6393        let relay = MemoryRelay::new();
6394        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
6395        let extra = ChannelId([0x77; 32]);
6396        let session = SessionGuard::capture();
6397        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
6398        let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
6399        assert!(with_extra.channel(&extra).is_some());
6400        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
6401        let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
6402        assert!(after.channel(&extra).is_none());
6403
6404        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6405        assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
6406        assert_eq!(reloaded.channels.len(), 1);
6407    }
6408
6409    #[tokio::test]
6410    async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
6411        // channel_id is the sole DB primary key, so a bundle/replay reusing another
6412        // community's channel_id must NOT overwrite that row. It's skipped (not an
6413        // error — erroring would wedge all of this community's control persistence).
6414        let (_tmp, _guard, _owner) = init_test_db();
6415        let relay = MemoryRelay::new();
6416        let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
6417        let a_channel = a.channels[0].id;
6418        let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
6419        let b_channel = b.channels[0].id;
6420        // B's set includes a phantom whose id collides with A's channel.
6421        b.channels.push(ChannelV2 { id: a_channel, name: "phantom".into(), private: false, key: None, epoch: b.root_epoch });
6422
6423        crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
6424        // A's channel row is untouched.
6425        let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
6426        assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
6427        assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
6428        // B keeps its own channel but never acquired a row for the foreign id.
6429        let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
6430        assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
6431        assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
6432    }
6433
6434    /// A single relay that CAPS every query below the page size (modelling a real
6435    /// relay's maxFilterLimit) and honors `until` — so the join-verify walk MUST
6436    /// paginate to reach an old genesis. MemoryRelay can't model this (it unions then
6437    /// truncates the whole set), which is why a MemoryRelay flood test gives false
6438    /// confidence about the production `LiveTransport` behaviour.
6439    struct CappedRelay {
6440        events: Vec<Event>,
6441        cap: usize,
6442    }
6443    #[async_trait::async_trait]
6444    impl Transport for CappedRelay {
6445        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6446            Ok(())
6447        }
6448        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6449            Ok(())
6450        }
6451        async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
6452            let mut m: Vec<Event> = self
6453                .events
6454                .iter()
6455                .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
6456                .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
6457                .cloned()
6458                .collect();
6459            m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); // newest first
6460            m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
6461            Ok(m)
6462        }
6463    }
6464
6465    #[tokio::test]
6466    async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
6467        // The join-verify DoS mitigation, tested against a relay that caps below PAGE
6468        // (production behaviour MemoryRelay hides): a rogue root-holder buries the
6469        // genesis under junk, and the `until`-walk must page past it. Uses fixed OLD
6470        // timestamps so `until = now` includes everything and the walk is deterministic.
6471        let (_tmp, _guard, owner) = init_test_db();
6472        let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
6473        let g = control::genesis(&owner, meta, 1_000).unwrap();
6474        let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
6475
6476        let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
6477        let rogue = Keys::generate();
6478        let mut events: Vec<Event> = g.wraps.to_vec();
6479        for i in 0..250u64 {
6480            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
6481            let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
6482            events.push(wrap);
6483        }
6484        // Cap 100/query forces the walk across ~3 pages down to the genesis at ts 1000.
6485        let relay = CappedRelay { events, cap: 100 };
6486        let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
6487        assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
6488    }
6489
6490    #[tokio::test]
6491    async fn accept_parked_invite_joins_from_the_stored_bundle() {
6492        // The 3313 receive path: an invite is parked as its bundle JSON, then accepted
6493        // from the stored bundle (re-verifying the owner root over the network).
6494        let (bed, owner, member) = TestBed::new();
6495        bed.swap_to(&owner);
6496        let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
6497        let general = community.channels[0].id;
6498        send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
6499        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
6500        let bundle_json = serde_json::to_string(&bundle).unwrap();
6501        let inviter_hex = owner.keys.public_key().to_hex();
6502
6503        bed.swap_to(&member);
6504        let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
6505        assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
6506        assert!(joined.identity.verify());
6507        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
6508        // The join seeded the verified fold as the member's initial floor, so their
6509        // first follow can't roll below the state the join just showed.
6510        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
6511        assert!(
6512            crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
6513            "the joiner's control floor is seeded from the join-time fold"
6514        );
6515
6516        // The Guestbook memberlist now folds both participants.
6517        bed.swap_to(&owner);
6518        let members = memberlist(&bed.relay, &community).await.unwrap();
6519        assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
6520    }
6521
6522    #[tokio::test]
6523    async fn accept_parked_invite_rejects_a_forged_root() {
6524        // A forged-root parked bundle (real identity triple, attacker-chosen root) fails
6525        // accept — the shared accept path re-verifies the owner root, so a parked invite
6526        // gets the same eclipse protection as a live one.
6527        let (_tmp, _guard, _owner) = init_test_db();
6528        let relay = MemoryRelay::new();
6529        let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
6530        let mut forged = bundle_of(&community, None, None, None);
6531        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
6532        forged.community_root = fake.clone();
6533        for ch in &mut forged.channels {
6534            ch.key = fake.clone();
6535        }
6536        let bundle_json = serde_json::to_string(&forged).unwrap();
6537
6538        let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
6539        assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
6540    }
6541
6542    #[test]
6543    fn v2_and_v1_bundles_are_distinguishable_by_parse() {
6544        // The protocol discriminator the facade list/accept relies on: a v2 bundle
6545        // (self-certifying: owner + owner_salt + community_root) parses; a v1-shaped
6546        // one does not, so a parked invite routes to the right accept path.
6547        let owner = Keys::generate();
6548        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
6549        let hex = crate::simd::hex::bytes_to_hex_32;
6550        let v2 = invite::CommunityInvite {
6551            community_id: hex(&identity.community_id.0),
6552            owner: hex(&identity.owner_xonly),
6553            owner_salt: hex(&identity.owner_salt),
6554            community_root: hex(&[0x11; 32]),
6555            root_epoch: 0,
6556            channels: vec![],
6557            relays: vec!["wss://r".into()],
6558            name: "V2".into(),
6559            icon: None,
6560            expires_at: None,
6561            creator_npub: None,
6562            label: None,
6563            extra: Default::default(),
6564        };
6565        let v2_json = serde_json::to_string(&v2).unwrap();
6566        assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
6567        let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
6568        assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
6569    }
6570
6571    #[tokio::test]
6572    async fn verify_rejects_a_cross_community_owner_edition_replay() {
6573        // The eclipse-via-replay: an owner-signed edition from community X (eid == X.id)
6574        // rewrapped onto a FORGED community T's fake control plane must NOT authenticate
6575        // T. T's genesis has eid == T.id, so X's edition — a genuine owner signature but
6576        // a different eid — is not a valid proof of T's root. This is why "any owner
6577        // edition" is unsound and the eid==community_id genesis pin is required.
6578        let (_tmp, _guard, owner) = init_test_db();
6579
6580        // Community X (real), owned by `owner`.
6581        let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
6582        let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
6583        let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
6584
6585        // Forged community T: the real owner triple but an ATTACKER-chosen root.
6586        let t_identity = control::CommunityIdentity::mint(&owner.public_key());
6587        let fake_root = [0xEE; 32];
6588        let t = CommunityV2 {
6589            identity: t_identity,
6590            community_root: fake_root,
6591            root_epoch: Epoch(0),
6592            name: "T".into(),
6593            description: None,
6594            relays: vec!["wss://r".into()],
6595            channels: vec![],
6596            dissolved: false,
6597            created_at_ms: 0,
6598        };
6599        // Rewrap X's owner-signed genesis onto T's fake control plane (the attacker
6600        // controls the fake root, so they can derive its control group key).
6601        let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
6602        let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
6603        let relay = MemoryRelay::new();
6604        relay.publish(&replayed, &t.relays).await.unwrap();
6605
6606        let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
6607        assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
6608    }
6609
6610    /// LIVE smoke test (network) — ignored by default. Creates a v2 community on a
6611    /// REAL relay via `LiveTransport`, sends a message, fetches it back, and mints
6612    /// a public link. A fresh throwaway identity in an isolated temp data dir, so
6613    /// it never touches real accounts. Run explicitly:
6614    /// ```sh
6615    /// cargo test -p vector-core -- --ignored --nocapture live_smoke
6616    /// ```
6617    #[tokio::test]
6618    #[ignore = "hits a real relay over the network"]
6619    async fn live_smoke_create_send_fetch_on_a_real_relay() {
6620        use crate::community::transport::LiveTransport;
6621        use nostr_sdk::prelude::ToBech32;
6622
6623        let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
6624        let relays = vec![relay.clone()];
6625
6626        // Isolated account + data dir (a fresh throwaway key — never a real account).
6627        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
6628        crate::db::close_database();
6629        crate::db::clear_id_caches();
6630        let tmp = tempfile::tempdir().unwrap();
6631        // Bring your own key (VECTOR_SMOKE_NSEC) to create a community you can log
6632        // into elsewhere; otherwise a fresh throwaway.
6633        let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
6634            Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
6635            Err(_) => Keys::generate(),
6636        };
6637        let npub = keys.public_key().to_bech32().unwrap();
6638        // Off by default (never leak secrets from a committed test); set
6639        // VECTOR_SMOKE_PRINT_NSEC=1 to print the owner nsec for cross-client login.
6640        if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
6641            println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
6642        }
6643        std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
6644        crate::db::set_app_data_dir(tmp.path().to_path_buf());
6645        crate::db::set_current_account(npub.clone()).unwrap();
6646        crate::db::init_database(&npub).unwrap();
6647        crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
6648        crate::state::set_my_public_key(keys.public_key());
6649        println!("[smoke] throwaway identity {npub}");
6650
6651        // A live client (LiveTransport rides the global NOSTR_CLIENT + warms relays).
6652        let client = nostr_sdk::ClientBuilder::new().signer(keys.clone()).build();
6653        client.pool().add_relay(relay.as_str(), nostr_sdk::RelayOptions::default()).await.ok();
6654        client.connect().await;
6655        crate::state::set_nostr_client(client);
6656        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
6657
6658        // Create → send → fetch-back → verify.
6659        let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
6660        let general = community.channels[0].id;
6661        println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
6662
6663        let text = "hello from a Vector Concord v2 live smoke test";
6664        let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
6665        println!("[smoke] sent message {sent_id}");
6666
6667        // Give the relay a moment to store + be ready to serve it.
6668        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
6669
6670        let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
6671        let texts: Vec<String> = page
6672            .iter()
6673            .filter_map(|f| match &f.event {
6674                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
6675                _ => None,
6676            })
6677            .collect();
6678        println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
6679        assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
6680
6681        // Mint a shareable v2 link (the thing a bot hands out).
6682        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
6683        println!("[smoke] invite link: {}", link.url);
6684        println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
6685    }
6686
6687    #[tokio::test]
6688    async fn chat_ops_react_edit_delete_round_trip() {
6689        let (bed, owner, _member) = TestBed::new();
6690        bed.swap_to(&owner);
6691        let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
6692        let general = community.channels[0].id;
6693        let me_hex = owner.keys.public_key().to_hex();
6694
6695        let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
6696        send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
6697            .await
6698            .unwrap();
6699        send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
6700        send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
6701
6702        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
6703        let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
6704        let mut saw = (false, false, false);
6705        for f in &page {
6706            match &f.event {
6707                ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
6708                    assert_eq!(emoji, ":fire:");
6709                    assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
6710                    saw.0 = true;
6711                }
6712                ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
6713                    assert_eq!(new_content, "edited");
6714                    saw.1 = true;
6715                }
6716                ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
6717                _ => {}
6718            }
6719        }
6720        assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
6721    }
6722
6723    #[tokio::test]
6724    async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
6725        let (bed, owner, _member) = TestBed::new();
6726        bed.swap_to(&owner);
6727        let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
6728        let general = community.channels[0].id;
6729        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
6730
6731        // A live subscriber sees the 21059 wrap and it opens as Typing…
6732        let mut sub = bed.relay.subscribe(Query {
6733            kinds: vec![stream::KIND_WRAP_EPHEMERAL],
6734            authors: vec![group.pk_hex()],
6735            ..Default::default()
6736        });
6737        send_typing(&bed.relay, &community, &general).await.unwrap();
6738        let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
6739        assert!(
6740            matches!(chat::open_chat_event(&wrap, &group, &general, community.root_epoch), Ok(ChatEvent::Typing { .. })),
6741            "the ephemeral wrap opens as a Typing event"
6742        );
6743
6744        // …while nothing durable is stored (relays never keep the ephemeral tier),
6745        // so channel history stays free of typing noise.
6746        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
6747        assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
6748    }
6749
6750    #[tokio::test]
6751    async fn send_chat_message_threads_the_reply_and_extra_tags() {
6752        let (bed, owner, _member) = TestBed::new();
6753        bed.swap_to(&owner);
6754        let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
6755        let general = community.channels[0].id;
6756        let me_hex = owner.keys.public_key().to_hex();
6757
6758        let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
6759        let imeta = nostr_sdk::prelude::Tag::custom(
6760            nostr_sdk::prelude::TagKind::custom("imeta"),
6761            ["url https://e/blob".to_string(), "m image/png".to_string()],
6762        );
6763        let child_id = send_chat_message(
6764            &bed.relay, &community, &general, "child",
6765            Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
6766        )
6767        .await
6768        .unwrap();
6769
6770        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
6771        let child = page
6772            .iter()
6773            .find_map(|f| match &f.event {
6774                ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
6775                _ => None,
6776            })
6777            .expect("the reply message round-trips");
6778        let reply = child.1.as_ref().expect("the reply reference is carried");
6779        assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
6780        assert_eq!(reply.author, Some(owner.keys.public_key()));
6781        assert!(
6782            child.0.rumor.tags.iter().any(|t| t.kind() == nostr_sdk::prelude::TagKind::custom("imeta")),
6783            "the imeta attachment tag rides the rumor verbatim"
6784        );
6785    }
6786
6787    #[tokio::test]
6788    async fn a_kick_needs_kick_authority_and_removes_the_target() {
6789        let (bed, owner, member) = TestBed::new();
6790        bed.swap_to(&owner);
6791        let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
6792
6793        // The target announces a Join (as an accepted invite would).
6794        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
6795        let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
6796        let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
6797        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
6798        let before = memberlist(&bed.relay, &community).await.unwrap();
6799        assert!(before.contains(&member.keys.public_key()), "the join lands first");
6800
6801        // An unprivileged member's kick of the owner is refused locally…
6802        bed.swap_to(&member);
6803        let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
6804        assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
6805
6806        // …and the owner (supreme, no grant needed) kicks the member out.
6807        bed.swap_to(&owner);
6808        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
6809        let after = memberlist(&bed.relay, &community).await.unwrap();
6810        assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
6811        assert!(after.contains(&owner.keys.public_key()), "the owner remains");
6812    }
6813
6814    #[tokio::test]
6815    async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
6816        let (bed, owner, member) = TestBed::new();
6817        bed.swap_to(&owner);
6818        let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
6819        let member_pk = member.keys.public_key();
6820        let member_hex = member_pk.to_hex();
6821        let owner_hex = owner.keys.public_key().to_hex();
6822
6823        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
6824        let view = fetch_authority(&bed.relay, &community).await;
6825        assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
6826        assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
6827
6828        // A second grant (any device) converges on the SAME role entity — and a
6829        // repeat is a no-op, not a version bump.
6830        let second = Keys::generate().public_key();
6831        grant_admin(&bed.relay, &community, &second).await.unwrap();
6832        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
6833        let view = fetch_authority(&bed.relay, &community).await;
6834        assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
6835        assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
6836        let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
6837        assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
6838
6839        // Revoke strips ONLY the admin role and de-authorizes.
6840        revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
6841        let view = fetch_authority(&bed.relay, &community).await;
6842        assert!(!view.roles.is_admin(&member_hex), "revoked");
6843        assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
6844        assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
6845    }
6846
6847    #[tokio::test]
6848    async fn follow_control_persists_the_roster_for_sync_local_reads() {
6849        let (bed, owner, member) = TestBed::new();
6850        bed.swap_to(&owner);
6851        let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
6852        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6853        let member_hex = member.keys.public_key().to_hex();
6854        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
6855
6856        // The passive follow folds + persists; the read is then LOCAL (v1 parity).
6857        let session = crate::state::SessionGuard::capture();
6858        follow_control(&bed.relay, &community, &session).await.unwrap();
6859        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
6860        assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
6861
6862        // A withholding relay serves nothing — an empty fold raises no gap flag, and
6863        // the stored roster must be RETAINED, never wiped.
6864        let withholding = MemoryRelay::new();
6865        let _ = follow_control(&withholding, &community, &session).await;
6866        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
6867        assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
6868
6869        // A real revocation (a NEWER grant edition) does replace it.
6870        revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
6871        follow_control(&bed.relay, &community, &session).await.unwrap();
6872        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
6873        assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
6874    }
6875
6876    #[tokio::test]
6877    async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
6878        let (bed, owner, member) = TestBed::new();
6879        bed.swap_to(&owner);
6880        let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
6881
6882        bed.swap_to(&member);
6883        let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
6884        assert!(err.contains("owner"), "refused before any publish: {err}");
6885
6886        // The deterministic admin-role entity stays unsquatted — the owner's later
6887        // legitimate mint is version 1 and folds cleanly.
6888        bed.swap_to(&owner);
6889        let view = fetch_authority(&bed.relay, &community).await;
6890        assert!(view.roles.roles.is_empty(), "no role edition landed");
6891        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
6892        let view = fetch_authority(&bed.relay, &community).await;
6893        assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
6894    }
6895
6896    #[tokio::test]
6897    async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
6898        let (bed, owner, member) = TestBed::new();
6899        bed.swap_to(&owner);
6900        let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
6901        let member_pk = member.keys.public_key();
6902
6903        // The member already holds a Mod role, granted through the real send path
6904        // (so this device's floors track both entities).
6905        let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
6906        set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
6907        grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
6908
6909        // A relay that withholds the control plane must refuse the merge — a blind
6910        // push would erase the Mod role at a higher version.
6911        let withholding = MemoryRelay::new();
6912        let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
6913        assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
6914
6915        // Against the full relay the merge preserves the Mod role.
6916        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
6917        let view = fetch_authority(&bed.relay, &community).await;
6918        let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
6919        assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
6920        assert!(grant.role_ids.contains(&mod_rid));
6921    }
6922
6923    #[tokio::test]
6924    async fn fetch_authority_reflects_a_granted_admin() {
6925        let (bed, owner, member) = TestBed::new();
6926        bed.swap_to(&owner);
6927        let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
6928        let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
6929        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
6930        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
6931
6932        let view = fetch_authority(&bed.relay, &community).await;
6933        let member_hex = member.keys.public_key().to_hex();
6934        assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
6935        assert!(
6936            view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
6937            "an ADMIN_ALL grant carries KICK"
6938        );
6939        assert!(view.banned.is_empty());
6940    }
6941}