Skip to main content

vector_core/community/
service.rs

1//! Orchestration that ties the Community send/delete primitives to persistence,
2//! with multi-account safety. This is the layer Tauri commands are thin wrappers
3//! over: it publishes a message AND retains its ephemeral key (so the sender can
4//! later delete it), and deletes by loading that retained key back.
5//!
6//! Every method is `std::sync::Arc<crate::db::Session>`-gated: a `swap_session` can happen at any await
7//! point, and persisting an ephemeral secret (or reading one) must never cross into
8//! the wrong account's DB.
9
10use nostr_sdk::prelude::{FinalizeEvent, FinalizeEventAsync};
11use nostr_sdk::prelude::{Event, EventId, Keys, Tag, ToBech32};
12
13use super::invite::CommunityInvite;
14use super::public_invite::{
15    self, build_public_invite_event, locator_hex, parse_public_invite_event, PublicInviteBundle,
16};
17use super::send::{delete_own_message, publish_signed_message};
18use super::transport::{Evidence, Query, Transport};
19use super::{Channel, Community};
20use crate::stored_event::event_kind;
21
22/// The active signer for authority actions (bunker support): the live client's signer — which covers a
23/// NIP-46 bunker — falling back to the local vault keys when there is no client OR the client has no
24/// signer attached (local accounts, headless/CLI paths, and tests). Every keyless control edition +
25/// moderation hide signs through this, so a bunker account can create AND administer a community. (The
26/// REKEY path is the one exception — its blob locator needs a raw ECDH the signer can't expose, so it
27/// still requires a local key; the ban/privatize flows fail-fast for bunker accounts.)
28/// Max communities a device may hold locally. The synced Community List is a single NIP-44 event
29/// (65 KB plaintext); past the cap even the slimmed list can't encrypt, so a NEW join/create is
30/// rejected above it (the user leaves one to make room).
31pub const MAX_COMMUNITIES: usize = 50;
32
33/// Reject a NEW join/create when already at [`MAX_COMMUNITIES`] local memberships. Counts the synced
34/// Community List (the thing that overflows). Re-accepting a community already held is exempt — the
35/// caller checks membership before calling this.
36fn enforce_community_cap() -> Result<(), String> {
37    let held = super::list::load_local_list().entries.len();
38    if held >= MAX_COMMUNITIES {
39        return Err(format!(
40            "You've reached the limit of {} communities. Leave one to join another.",
41            MAX_COMMUNITIES
42        ));
43    }
44    Ok(())
45}
46
47/// Create a brand-new Community end-to-end: mint keys + the default channel, persist
48/// it locally, and publish its GroupRoot + ChannelMetadata to the Community's relays.
49/// Returns the created Community. (The caller then runs the subscription refresh so it
50/// starts receiving.)
51pub async fn create_community<T: Transport + ?Sized>(
52    transport: &T,
53    name: &str,
54    default_channel_name: &str,
55    relays: Vec<String>,
56) -> Result<Community, String> {
57    crate::db::scoped(async move {
58        enforce_community_cap()?;
59        let mut community = Community::create(name, default_channel_name, relays);
60        // Owner attestation — MANDATORY: a community cannot exist without the root that anchors its
61        // authority graph. It binds the community id to the creator's identity, signed by the owner's identity
62        // signer. The proven owner is later DERIVED by verifying this, never an unverified claim. Sign via the
63        // local vault when present (local accounts + tests), else the
64        // session signer (bunker / NIP-46 / NIP-55). No signer at all → creation fails, by design.
65        let owner_pk = crate::state::my_public_key().ok_or("cannot create a community without an identity")?;
66        let unsigned = super::owner::build_owner_attestation_unsigned(owner_pk, &community.id.to_hex());
67        // Use the local vault ONLY if it actually holds the active identity's key — else a stale/mismatched
68        // local secret would sign the attestation as the WRONG owner (or break verification). On mismatch,
69        // fall through to the client signer, which is the authority that produced `my_public_key()`.
70        let attestation = if let Some(keys) = crate::state::MY_SECRET_KEY.to_keys().filter(|k| k.public_key() == owner_pk) {
71            unsigned.finalize(&keys).map_err(|e| format!("sign owner attestation: {e}"))?
72        } else {
73            // No matching local key: sign through the session signer (bunker / NIP-55).
74            // `active_signer()` fails closed, so a signer-less session still can't create —
75            // it no longer needs a live client to get there.
76            let signer = crate::signer::active_signer()
77                // Keeps the "identity signer" wording: the owner attestation is mandatory,
78                // so no usable signer means creation must not proceed.
79                .map_err(|e| format!("cannot create a community without an identity signer: {e}"))?;
80            unsigned.finalize_async(&signer).await.map_err(|e| format!("sign owner attestation: {e}"))?
81        };
82        community.owner_attestation = Some(attestation.as_json());
83        // Minting + the DB write straddle the (above) signer round-trip, so re-check before persist.
84        // CREATION is the deliberate exception to publish-first: we save locally BEFORE publishing because
85        // (a) no peers exist yet, so there is no shared view to diverge from, and (b) the keys are
86        // fresh-random — losing them (e.g. by rolling back on a publish hiccup) would orphan the community
87        // irrecoverably. A failed publish leaves a local community the owner can re-publish
88        // (`republish_community_metadata`), not a cross-member divergence.
89        crate::db::community::save_community(&community)?;
90
91        // The owner signs every genesis edition with their REAL identity (keyless control plane) via the
92        // active signer — local vault OR a NIP-46 bunker.
93        let signer = crate::signer::active_signer()?;
94        let cid = community.id.to_hex();
95        let created = web_time::SystemTime::now()
96            .duration_since(web_time::UNIX_EPOCH)
97            .map(|d| d.as_secs())
98            .unwrap_or(0);
99
100        // The genesis control plane: GroupRoot (vsk=0) + each channel's display metadata (vsk=2) + the
101        // auto Admin role (vsk=1), all real-npub 3308 editions signed by the owner. The Admin role is
102        // DATA, not a hardcoded flag (Mod/custom roles are additive later); the owner takes no grant (owner
103        // = implicit position 0, never a Role). Build + collect each (entity_hex, self_hash) head, publish
104        // each, and only AFTER every publish succeeds record the heads — so a mid-create publish failure
105        // never leaves heads for a partially-published genesis (which would make a later base rotation's
106        // re-anchor coverage gate trip forever on an entity the relay never received).
107        let admin = super::roles::Role::admin(crate::simd::hex::bytes_to_hex_32(&super::random_32()));
108        let root_meta = super::metadata::CommunityMetadata::of(&community);
109        let root_inner = super::roster::build_community_root_edition_unsigned(owner_pk, &community.id, &root_meta, 1, None, created, None)?
110            .finalize_async(&signer).await.map_err(|e| format!("sign genesis group-root: {e}"))?;
111        let role_inner = super::roster::build_role_edition_unsigned(owner_pk, &admin, 1, None, created, None)?
112            .finalize_async(&signer).await.map_err(|e| format!("sign genesis admin-role: {e}"))?;
113        // (entity_hex, self_hash, inner_id-for-display-entities). The GroupRoot + channels record their
114        // inner_id so a same-version genesis fork resolves by the deterministic tiebreak; the role doesn't
115        // converge (authority record), so it carries None.
116        let mut heads: Vec<(String, [u8; 32], Option<[u8; 32]>)> = vec![
117            (cid.clone(), super::version::edition_hash(&community.id.0, 1, None, root_inner.content.as_bytes()), Some(root_inner.id.to_bytes())),
118            (admin.role_id.clone(), super::version::edition_hash(&crate::simd::hex::hex_to_bytes_32(&admin.role_id), 1, None, role_inner.content.as_bytes()), None),
119        ];
120        let mut to_publish: Vec<Event> = vec![
121            super::roster::seal_control_edition(&Keys::generate(), &root_inner, &community.server_root_key, &community.id, community.server_root_epoch)?,
122            super::roster::seal_control_edition(&Keys::generate(), &role_inner, &community.server_root_key, &community.id, community.server_root_epoch)?,
123        ];
124        for channel in &community.channels {
125            let meta = super::metadata::ChannelMetadata { name: channel.name.clone() };
126            let inner = super::roster::build_channel_metadata_edition_unsigned(owner_pk, &channel.id, &meta, 1, None, created, None)?
127                .finalize_async(&signer).await.map_err(|e| format!("sign genesis channel-metadata: {e}"))?;
128            heads.push((channel.id.to_hex(), super::version::edition_hash(&channel.id.0, 1, None, inner.content.as_bytes()), Some(inner.id.to_bytes())));
129            to_publish.push(super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?);
130        }
131        // Publish the genesis editions durably: each returns once a relay ACKs (the laggards thread in the
132        // background) and throws if NO relay accepts within the confirm window — so a dead relay set fails the
133        // create loudly instead of recording heads for editions that never reached the network.
134        for outer in &to_publish {
135            transport.publish_durable(outer, &community.relays).await?;
136        }
137        // Every edition reached at least one relay — now record each head + cache the Admin role (gated on the
138        for (entity_hex, hash, inner_id) in &heads {
139            let _ = match inner_id {
140                Some(id) => crate::db::community::set_edition_head_with_id(&cid, entity_hex, 1, hash, id),
141                None => crate::db::community::set_edition_head(&cid, entity_hex, 1, hash),
142            };
143        }
144        let roster = super::roles::CommunityRoles { roles: vec![admin], grants: Vec::new() };
145        let _ = crate::db::community::set_community_roles(&cid, &roster, created as i64);
146        Ok(community)
147    })
148    .await
149}
150
151/// Publish a Community message and retain its ephemeral key in the account DB so the
152/// sender can delete it later. Returns the published outer event.
153pub async fn send_message<T: Transport + ?Sized>(
154    transport: &T,
155    community: &Community,
156    channel: &Channel,
157    author: &Keys,
158    content: &str,
159    ms: u64,
160) -> Result<Event, String> {
161    crate::db::scoped(async move {
162        let session = crate::db::current_session();
163        // Build + sign the inner explicitly so we know the message_id (the deletion key) up
164        // front, then publish via the signed path. Identical wire output to the old
165        // publish_message route.
166        let inner = super::envelope::build_inner_event(author.public_key(), &channel.id, channel.epoch, content, ms, None)
167            .finalize(author)
168            .map_err(|e| e.to_string())?;
169        let (outer, ephemeral) = publish_signed_message(transport, community, channel, &inner, false).await?;
170        // The publish straddled network I/O; bail before writing to the (possibly
171        // swapped) account DB.
172        if !session.is_live() {
173            return Err("account changed during send; not persisting message key".to_string());
174        }
175        crate::db::community::store_message_key(&inner.id.to_hex(), &outer.id.to_hex(), &ephemeral, &community.relays)?;
176        Ok(outer)
177    })
178    .await
179}
180
181/// Publish a message whose inner authorship event was signed externally (via the active
182/// signer — local OR bunker) and retain its ephemeral key. Use this from the command
183/// layer where `client.signer()` is available; it gives bunker accounts send parity with
184/// DMs. (Local-only callers/tests can use [`send_message`].)
185pub async fn send_signed_message<T: Transport + ?Sized>(
186    transport: &T,
187    community: &Community,
188    channel: &Channel,
189    inner: &Event,
190) -> Result<Event, String> {
191    crate::db::scoped(async move {
192        let session = crate::db::current_session();
193        let (outer, ephemeral) = publish_signed_message(transport, community, channel, inner, false).await?;
194        if !session.is_live() {
195            return Err("account changed during send; not persisting message key".to_string());
196        }
197        crate::db::community::store_message_key(&inner.id.to_hex(), &outer.id.to_hex(), &ephemeral, &community.relays)?;
198        Ok(outer)
199    })
200    .await
201}
202
203/// Announce presence (join/leave) into a channel: a kind-3306 inner signed by the active identity,
204/// published under a fresh ephemeral outer. Content is `"leave"`, plain `"join"`, or — for a join via a
205/// public invite — a small JSON `{"by":"<inviter npub>","l":"<label>"}` carrying attribution (which
206/// link/source brought this member; members-only). Client best-practice (not enforced); no deletion key
207/// retained. Callers treat failure as non-fatal. `attribution` = `Some((inviter_npub, label))` on an
208/// invite-join, else `None`.
209/// Build + sign a presence (3306) inner event WITHOUT publishing. Lets the caller record the local
210/// system event first (memory→DB, like an outgoing message) and publish in the background — the relay
211/// echo then dedups by this inner's id. `inner.id` is the system-event dedup key.
212pub async fn build_presence(
213    channel: &Channel,
214    joined: bool,
215    attribution: Option<(String, Option<String>)>,
216) -> Result<nostr_sdk::prelude::Event, String> {
217    let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
218    let ms = web_time::SystemTime::now()
219        .duration_since(web_time::UNIX_EPOCH)
220        .map(|d| d.as_millis() as u64)
221        .unwrap_or(0);
222    let content = match (joined, attribution) {
223        (false, _) => "leave".to_string(),
224        (true, Some((by, label))) => serde_json::json!({ "by": by, "l": label }).to_string(),
225        (true, None) => "join".to_string(),
226    };
227    let unsigned = super::envelope::build_inner_typed(
228        author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, &content, ms, None, &[],
229    );
230    let signer = crate::signer::active_signer()?;
231    unsigned.finalize_async(&signer).await.map_err(|e| format!("Failed to sign presence: {e}"))
232}
233
234/// Publish a pre-built presence inner (from [`build_presence`]) to the channel's recipient set.
235pub async fn publish_presence_event<T: Transport + ?Sized>(
236    transport: &T,
237    community: &Community,
238    channel: &Channel,
239    inner: &nostr_sdk::prelude::Event,
240) -> Result<(), String> {
241    let _ = publish_signed_message(transport, community, channel, inner, true).await?;
242    Ok(())
243}
244
245pub async fn publish_presence<T: Transport + ?Sized>(
246    transport: &T,
247    community: &Community,
248    channel: &Channel,
249    joined: bool,
250    attribution: Option<(String, Option<String>)>,
251) -> Result<(), String> {
252    let inner = build_presence(channel, joined, attribution).await?;
253    publish_presence_event(transport, community, channel, &inner).await
254}
255
256/// Publish a WebXDC realtime peer signal (3310) into a channel: an advertisement of the local
257/// Iroh node for a Mini App session (`node_addr` = Some) or a peer-left (`node_addr` = None).
258/// The Community-transport twin of the NIP-17 peer-advertisement/peer-left DM rumors — signed
259/// by the member's real identity (a member can't forge another player's presence), sealed under
260/// the channel epoch key like presence. Callers treat failure as non-fatal (a missed ad only
261/// delays discovery; the next re-advertise covers it).
262pub async fn publish_webxdc_signal<T: Transport + ?Sized>(
263    transport: &T,
264    community: &Community,
265    channel: &Channel,
266    topic_id: &str,
267    node_addr: Option<&str>,
268) -> Result<(), String> {
269    let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
270    let ms = web_time::SystemTime::now()
271        .duration_since(web_time::UNIX_EPOCH)
272        .map(|d| d.as_millis() as u64)
273        .unwrap_or(0);
274    let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
275    let unsigned = super::envelope::build_inner_typed(
276        author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_WEBXDC, &content, ms, None, &[],
277    );
278    let signer = crate::signer::active_signer()?;
279    let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("Failed to sign webxdc signal: {e}"))?;
280    let _ = publish_signed_message(transport, community, channel, &inner, true).await?;
281    Ok(())
282}
283
284/// Publish a typing indicator (3311) into a channel: an inner "typing" event signed by the member,
285/// sealed under the channel epoch key like presence. The Community-transport twin of the NIP-17
286/// typing rumor. Ephemeral — never persisted/folded; the latency-sensitive single-attempt path
287/// (`durable = false`), and callers treat failure as non-fatal (a dropped keystroke ping is harmless;
288/// the next one ~every few seconds covers it).
289pub async fn publish_typing_signal<T: Transport + ?Sized>(
290    transport: &T,
291    community: &Community,
292    channel: &Channel,
293) -> Result<(), String> {
294    let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
295    let ms = web_time::SystemTime::now()
296        .duration_since(web_time::UNIX_EPOCH)
297        .map(|d| d.as_millis() as u64)
298        .unwrap_or(0);
299    let unsigned = super::envelope::build_inner_typed(
300        author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_TYPING, "typing", ms, None, &[],
301    );
302    let signer = crate::signer::active_signer()?;
303    let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("Failed to sign typing signal: {e}"))?;
304    let _ = publish_signed_message(transport, community, channel, &inner, false).await?;
305    Ok(())
306}
307
308/// Persist an inbound WebXDC peer signal as a kind-30078 event row — the SAME shape the DM
309/// peer-advertisement handler writes (content `peer-advertisement`/`peer-left`, `reference_id`
310/// = topic, `webxdc-topic`/`webxdc-node-addr` tags) — so the miniapp layer's
311/// `get_active_peer_advertisements` (latest-per-npub, left-tombstone-aware) reads both
312/// transports identically. This is what lets a member who closed Vector mid-session rediscover
313/// the active players on reopen. Idempotent via `event_exists`.
314pub async fn persist_webxdc_signal(
315    channel_hex: &str,
316    npub: &str,
317    topic_id: &str,
318    node_addr: Option<&str>,
319    event_id: &str,
320    created_at: u64,
321) {
322    if crate::db::events::event_exists(event_id).unwrap_or(true) {
323        return;
324    }
325    // Sender-claimed timestamp: clamp into the near future so a forged far-future ad
326    // can't outrank every later genuine peer-left in the latest-per-npub read.
327    let now_secs = web_time::SystemTime::now()
328        .duration_since(web_time::UNIX_EPOCH)
329        .unwrap_or_default()
330        .as_secs();
331    let created_at = created_at.min(now_secs + 300);
332    let Ok(chat_id) = crate::db::id_cache::get_or_create_chat_id(channel_hex) else { return };
333    let mut tags = vec![
334        vec!["webxdc-topic".to_string(), topic_id.to_string()],
335        vec!["d".to_string(), "vector-webxdc-peer".to_string()],
336    ];
337    if let Some(addr) = node_addr {
338        tags.push(vec!["webxdc-node-addr".to_string(), addr.to_string()]);
339    }
340    let event = crate::stored_event::StoredEvent {
341        id: event_id.to_string(),
342        kind: crate::stored_event::event_kind::APPLICATION_SPECIFIC,
343        chat_id,
344        user_id: None,
345        content: if node_addr.is_some() { "peer-advertisement" } else { "peer-left" }.to_string(),
346        tags,
347        reference_id: Some(topic_id.to_string()),
348        created_at,
349        received_at: web_time::SystemTime::now()
350            .duration_since(web_time::UNIX_EPOCH)
351            .unwrap_or_default()
352            .as_millis() as u64,
353        mine: false,
354        pending: false,
355        failed: false,
356        wrapper_event_id: None,
357        npub: Some(npub.to_string()),
358        preview_metadata: None,
359    };
360    if let Err(e) = crate::db::events::save_event(&event).await {
361        crate::log_warn!("[community] failed to persist webxdc peer signal: {e}");
362    }
363}
364
365/// Publish a cooperative kick (3309) of `target_hex` into `channel`: a real-npub-signed inner directive
366/// (content = the target's hex pubkey) carrying the actor's `vac` authority citation. NOT a rekey and NOT
367/// folded — the kicked client self-removes on receipt (drops the community keys + wipes local chat data);
368/// peers drop the target from their observed member list. The actor must hold `KICK` and strictly outrank
369/// the target (the owner is never a valid target); this is the sender-side half of the rule peers
370/// re-verify on receipt. For a malicious target that ignores the kick, escalate to a BAN.
371/// Signs via the active client signer, so a bunker (NIP-46) identity works without exposing the secret.
372/// On removal (kick/ban), strip the target's roles so their authority doesn't dangle — a removed admin
373/// would otherwise silently regain @admin on re-add, and the roster would keep listing a non-member as an
374/// admin. Best-effort: a no-op if the target holds no role; a SKIP (logged) if the remover lacks
375/// `MANAGE_ROLES`/outrank for any held role (a future mid-tier remover) — the kick/ban still neutralizes
376/// them, and leaving the grant beats a partial strip. Publishes the full revoke (empty grant) when
377/// authorized for EVERY held role.
378async fn strip_member_roles_on_removal<T: Transport + ?Sized>(
379    transport: &T,
380    community: &Community,
381    member_hex: &str,
382) {
383    let cid = community.id.to_hex();
384    let roster = match crate::db::community::get_community_roles(&cid) {
385        Ok(r) => r,
386        Err(_) => return,
387    };
388    let held: Vec<String> = roster
389        .grants
390        .iter()
391        .find(|g| g.member == member_hex)
392        .map(|g| g.role_ids.clone())
393        .unwrap_or_default();
394    if held.is_empty() {
395        return; // plain member — no authority to strip
396    }
397    for role_id in &held {
398        if caller_can_manage_role(community, &roster, role_id, member_hex).is_err() {
399            crate::log_warn!(
400                "removal: not authorized to revoke role {role_id} of {member_hex}; leaving the grant (kick/ban still neutralizes)"
401            );
402            return;
403        }
404    }
405    if let Err(e) = set_member_grant(transport, community, member_hex, Vec::new()).await {
406        crate::log_warn!("removal: role-strip publish failed for {member_hex}: {e}");
407    }
408}
409
410pub async fn publish_kick<T: Transport + ?Sized>(
411    transport: &T,
412    community: &Community,
413    channel: &Channel,
414    target_hex: &str,
415) -> Result<String, String> {
416    let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
417    let me = author_pk.to_hex();
418    let cid = community.id.to_hex();
419    // hierarchy gate: hold KICK + strictly outrank the target (owner is never a valid target). Mirror
420    // of publish_banlist's gate; peers re-verify the same rule against their floor-protected roster.
421    {
422        let owner = proven_owner_hex(community);
423        let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
424        if !roster.can_act_on_member(&me, owner.as_deref(), target_hex, super::roles::Permissions::KICK) {
425            return Err("you can't kick a member who outranks you (or the owner)".to_string());
426        }
427    }
428    let ms = web_time::SystemTime::now()
429        .duration_since(web_time::UNIX_EPOCH)
430        .map(|d| d.as_millis() as u64)
431        .unwrap_or(0);
432    // pinned authority: a non-owner kicker cites the grant that authorizes them (owner cites nothing).
433    let citation = authority_citation(community, &me);
434    let extra: Vec<nostr_sdk::prelude::Tag> = citation.iter().map(|c| c.to_tag()).collect();
435    let unsigned = super::envelope::build_inner_full(
436        author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_KICK, target_hex, ms, None, &[], &extra,
437    );
438    let signer = crate::signer::active_signer()?;
439    let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("Failed to sign kick: {e}"))?;
440    publish_signed_message(transport, community, channel, &inner, true).await?;
441    // Removal strips authority: revoke the kicked member's roles too (best-effort) so a kicked admin
442    // doesn't rejoin (fresh invite) silently still admin, and no non-member lingers in the roster.
443    strip_member_roles_on_removal(transport, community, target_hex).await;
444    // Return the inner id so the caller can record a local "Member Left" that dedups with the relay echo.
445    Ok(inner.id.to_hex())
446}
447
448
449/// Replace the Community banlist and publish it as a real-npub-signed 3308 EDITION (vsk=4) at the
450/// community-scoped banlist locator (keyless; foldable + re-anchorable). `banned_hex`
451/// is the full new list (latest-wins). The actor's inner signature IS the authority proof; every member
452/// re-verifies it held `BAN` against the authorized roster on receipt. Publish FIRST, then
453/// persist locally on success — a failed publish must not leave us enforcing a ban no one else sees.
454pub async fn publish_banlist<T: Transport + ?Sized>(
455    transport: &T,
456    community: &Community,
457    banned_hex: &[String],
458) -> Result<(), String> {
459    crate::db::scoped(async move {
460        let cid = community.id.to_hex();
461        // Keyless model: sign with the actor's own identity via the active signer (local vault OR a NIP-46
462        // bunker). `author` is the active pubkey; `signer` signs the unsigned edition below.
463        let signer = crate::signer::active_signer()?;
464        let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the banlist edition")?;
465        // hierarchy gate: the actor must hold BAN and strictly outrank every member in the DELTA
466        // both those being ADDED (ban) and those being REMOVED (unban). Gating only additions would let a
467        // low-ranked admin undo a superior's ban or wholesale-clear the list. The owner is never a valid
468        // target. This is the sender-side half of the rule peers re-verify on receipt.
469        {
470            let me = actor_pk.to_hex();
471            let owner = proven_owner_hex(community);
472            let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
473            let current: std::collections::HashSet<String> =
474                crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
475            let next: std::collections::HashSet<&str> = banned_hex.iter().map(|s| s.as_str()).collect();
476            let added = banned_hex.iter().filter(|n| !current.contains(n.as_str()));
477            let removed = current.iter().filter(|n| !next.contains(n.as_str()));
478            for target in added.chain(removed) {
479                if !roster.can_act_on_member(&me, owner.as_deref(), target, super::roles::Permissions::BAN) {
480                    return Err("you can't ban or unban a member who outranks you (or the owner)".to_string());
481                }
482            }
483        }
484        // Fail-fast (bunker boundary): a newly-banned member in a PRIVATE community must be READ-CUT (a
485        // base rekey), and a rekey needs a RAW local key — its blob locator is an ECDH a NIP-46 bunker can't
486        // expose. Refuse BEFORE publishing anything, so we never half-apply (publish a ban we then can't
487        // enforce, leaving a "banned but still readable" member). Covers a pending prior cut too. A community
488        // admin who holds a local key can carry out the ban. (Public bans + unbans don't rekey → allowed.)
489        {
490            let prev: std::collections::HashSet<String> =
491                crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
492            let adds = banned_hex.iter().any(|n| !prev.contains(n.as_str()));
493            let cut_needed = (adds || crate::db::community::get_read_cut_pending(&cid)?) && !is_public(community)?;
494            if cut_needed && crate::state::MY_SECRET_KEY.to_keys().is_none() {
495                return Err("Banning someone from a private community cuts their read access, which needs a key rotation your account can't perform: it signs remotely (a NIP-46 bunker), and a rotation requires a local key. Ask a community admin who holds a local key to carry out the ban.".to_string());
496            }
497        }
498        // Next version in the banlist's own chain (single community-wide entity at the banlist locator).
499        let entity_id = super::derive::banlist_locator(&community.id);
500        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
501        let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
502            Some((v, h)) => (v + 1, Some(h)),
503            None => (1, None),
504        };
505        let created_at = web_time::SystemTime::now()
506            .duration_since(web_time::UNIX_EPOCH)
507            .map(|d| d.as_secs())
508            .unwrap_or(0);
509        // pinned authority: a non-owner banner cites the grant edition that authorizes them, so peers
510        // resolve the ban against that exact grant version (not their live roster). The owner cites nothing.
511        let citation = authority_citation(community, &actor_pk.to_hex());
512        let unsigned = super::roster::build_banlist_edition_unsigned(actor_pk, &community.id, banned_hex, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
513        let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign banlist edition: {e}"))?;
514        let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
515        let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
516
517        // Did this ban ADD anyone (vs the list we held)? Captured BEFORE the persist below so we can decide
518        // whether to cut read access. Unbans (removals) never rekey.
519        let newly_added: Vec<String> = {
520            let prev: std::collections::HashSet<String> =
521                crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
522            banned_hex.iter().filter(|n| !prev.contains(n.as_str())).cloned().collect()
523        };
524        let newly_banned = !newly_added.is_empty();
525
526        // Publish FIRST — advancing the head before a fallible publish would leave a phantom head (the next
527        // edition cites an unpublished predecessor → fold quarantines it forever). Re-check the session after
528        // the await: it may have straddled an account swap, and persisting then would write the wrong account.
529        transport.publish_durable(&outer, &community.relays).await?;
530        crate::db::community::set_community_banlist(&cid, banned_hex, created_at as i64)?;
531        crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
532
533        // Removal strips authority: revoke the roles of every NEWLY-banned member so their grant doesn't dangle
534        // — a banned admin would otherwise silently regain @admin on unban, and the roster would keep listing a
535        // removed member as admin. Best-effort, and BEFORE the read-cut so its re-anchor carries the revoked
536        // (empty) grant forward.
537        for member_hex in &newly_added {
538            strip_member_roles_on_removal(transport, community, member_hex).await;
539        }
540
541        // rekey-on-removal: in a PRIVATE community, a newly-banned member must also lose READ access, so
542        // re-seal the base to the surviving observed participants (`community_member_activity` excludes the
543        // banlist, so the just-banned member is dropped). A PUBLIC community does NOT rotate the base
544        // (anti-memberlist: no recipient set to wrap to, and a banned member could re-enter via a link
545        // anyway) — there the banlist alone suppresses them, and the UI must say "blocked," not "removed."
546        // Runs after the banlist is persisted (so the observed set already excludes the banned).
547        //
548        // rekey-on-removal read-cut. Re-seal if this ban ADDED someone, OR a prior re-seal is still
549        // pending (`read_cut_pending`) — the latter decouples recovery from the add-delta (which the durable
550        // banlist persist consumes), so a re-seal that failed on a previous ban is RETRIED here even when this
551        // call adds no one. Mark pending BEFORE the attempt (durable intent) and clear ONLY on success: a
552        // failure (total relay outage / re-anchor-withhold / mid-ban swap) leaves the flag set, so the next
553        // ban OR a community sync ([`retry_pending_read_cut`]) re-attempts it — no "blocked but not read-cut"
554        // member survives a transient failure. The re-seal publish is itself durable (×30 per relay).
555        let need_cut = (newly_banned || crate::db::community::get_read_cut_pending(&cid)?)
556
557            && !is_public(community)?;
558        if need_cut {
559            // `newly_banned` is a fresh exclusion delta → force a base epoch past the removal; otherwise this is
560            // a resume of an interrupted prior cut → keep its in-flight target.
561            run_read_cut(transport, community, newly_banned).await?;
562        }
563        Ok(())
564    })
565    .await
566}
567
568/// Is the local user in this community's (folded, cached) banlist? Drives BAN self-removal: a
569/// banned member tears down locally (drop the community keys + wipe local chat data) exactly like a kick,
570/// but CANNOT rejoin — re-detecting the ban on any later sync re-removes them, and admins can't invite a
571/// banned npub. Reads the cached banlist, so refresh it via [`fetch_and_apply_banlist`] first for an
572/// authoritative (realtime or boot) check.
573pub fn am_i_banned(community: &Community) -> bool {
574    let me = match crate::state::my_public_key() {
575        Some(p) => p.to_hex(),
576        None => return false,
577    };
578    crate::db::community::get_community_banlist(&community.id.to_hex())
579        .unwrap_or_default()
580        .iter()
581        .any(|b| b == &me)
582}
583
584/// Retry an outstanding PRIVATE-community read-cut re-seal, if one is pending. Called from the sync
585/// path so a re-seal that failed during a ban (e.g. a relay outage) AUTO-RECOVERS on the owner's next
586/// community sync — no manual re-ban needed. No-op if nothing is pending. If the community has since gone
587/// PUBLIC the read-cut is moot (anti-memberlist: a Public ban doesn't rotate the base), so the stale flag
588/// is cleared. Best-effort + idempotent; the re-seal authority (BAN) is enforced by `rotate_server_root`.
589pub async fn retry_pending_read_cut<T: Transport + ?Sized>(
590    transport: &T,
591    community: &Community,
592) -> Result<(), String> {
593    let cid = community.id.to_hex();
594    if !crate::db::community::get_read_cut_pending(&cid)? {
595        return Ok(());
596    }
597    if is_public(community)? {
598        crate::db::community::set_read_cut_pending(&cid, false)?; // moot in Public mode
599        return Ok(());
600    }
601    // Reload so the re-seal rotates from the FRESHEST root/epoch — the caller's `community` struct may
602    // predate a recent rotation, and rotating from a stale root would address the rekey under the wrong
603    // prior-root pseudonym. Pure resume (`fresh = false`): keep the in-flight target so an interrupted cut
604    // finishes without forcing an extra base rotation.
605    let fresh = crate::db::community::load_community(&community.id)?.ok_or("community no longer present")?;
606    run_read_cut(transport, &fresh, false).await
607}
608
609/// Fetch the Community's control plane and apply the folded banlist locally. The banlist is a 3308
610/// edition at the community-scoped banlist locator; the folded head is applied only if its signer held
611/// `BAN` in the authorized roster (the keyless authority gate) and it is strictly newer than the
612/// banlist edition we hold (refuse-downgrade by version). No authorized edition → local unchanged.
613/// ONE REQ for the entire control plane: fetch every kind-3308 edition at the control pseudonym(s) and
614/// fold the full roster (banlist + roles + invite-links + metadata) in a single pass. The per-slice
615/// `fetch_and_apply_*` functions and `fetch_and_apply_control` share this, so a sync/join/boot folds ONCE
616/// instead of issuing four identical REQs. Fetches at the CURRENT server-root epoch (re-anchoring keeps
617/// the complete plane reachable there); `z_tags` is a Vec so the addressing can extend if ever needed.
618async fn fetch_control_folded<T: Transport + ?Sized>(
619    transport: &T,
620    community: &Community,
621) -> Result<super::roster::FoldedRoster, String> {
622    fetch_control_folded_with(transport, community, Evidence::Quorum).await
623}
624
625async fn fetch_control_folded_with<T: Transport + ?Sized>(
626    transport: &T,
627    community: &Community,
628    evidence: Evidence,
629) -> Result<super::roster::FoldedRoster, String> {
630    // The control plane lives at the CURRENT server-root epoch — a rotation re-anchors it there, and all
631    // live publishes (grants/banlist/metadata/invite-links) seal at the same epoch. Fetch exactly that one
632    // (NOT a 0..=epoch range — a post-rotation joiner can't derive prior-epoch pseudonyms; the re-anchor
633    // guarantees the complete current plane is reachable here).
634    let z_tags = vec![super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch)];
635    // The fold is fail-closed on version-chain gaps and seeds from refuse-downgrade
636    // floors; Quorum coverage defeats a single fast-but-partial relay (which would
637    // otherwise gap-quarantine the head and wedge this seat on a stale plane).
638    // Callers whose result gates a DESTRUCTIVE write pass Evidence::Full.
639    let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags, evidence, ..Default::default() };
640    let raw = transport.fetch(&query, &community.relays).await?;
641    // Bound the AEAD work too (fold_roster re-caps the verify/fold): a relay
642    // flooding the coordinate must not buy unbounded decrypt attempts.
643    let inner_editions: Vec<Event> = raw
644        .iter()
645        .take(super::roster::MAX_CONTROL_EDITIONS)
646        .filter_map(|ev| super::roster::open_control_edition(ev, &community.server_root_key).ok())
647        .collect();
648    // VALID (opened) editions, NOT raw.len() — the admin-write isolation signal must mean "a relay served
649    // our actual control plane," so a relay returning only junk/unopenable events at the coordinate doesn't
650    // count as a response. (Floors still guard against stale/rollback; this just stops content-withholding
651    // from masquerading as connectivity.)
652    let fetched = inner_editions.len();
653    // Fold from the persisted per-entity floors (refuse-downgrade) so a withholding relay can't roll
654    // an entity's chain back to a since-revoked version. EPOCH-PRIMARY: seed only the floors recorded
655    // at the CURRENT epoch — a head from a PRIOR epoch belongs to a superseded founding, so that entity
656    // folds fresh from the new epoch's v1 genesis (which anchors cleanly at floor 0; not Policy-B, since a
657    // compacted genesis carries no prev_hash). Within the current epoch, refuse-downgrade + floor anchoring hold.
658    let current_epoch = community.server_root_epoch.0;
659    let floors: std::collections::HashMap<String, (u64, [u8; 32])> =
660        crate::db::community::get_all_edition_heads_epoched(&community.id.to_hex())?
661            .into_iter()
662            .filter(|(_, (epoch, _, _))| *epoch == current_epoch)
663            .map(|(entity, (_epoch, version, hash))| (entity, (version, hash)))
664            .collect();
665    let mut folded = super::roster::fold_roster(&inner_editions, &community.id, &floors);
666    folded.fetched = fetched; // openable editions the relays served (isolation signal for admin-write guards)
667    Ok(folded)
668}
669
670/// Fetch the control plane ONCE and apply every slice — banlist, roles, invite links, metadata — from a
671/// single REQ + single fold. Sync/join/boot call THIS instead of the four `fetch_and_apply_*` in sequence
672/// (which was four identical REQs). Banlist is applied first so a caller's subsequent `am_i_banned` sees the
673/// freshest list. Each slice is best-effort; one failing doesn't abort the rest. (Solo callers that need a
674/// single slice — e.g. revoke refreshing invite links — still use the individual `fetch_and_apply_*`.)
675pub async fn fetch_and_apply_control<T: Transport + ?Sized>(
676    transport: &T,
677    community: &Community,
678) -> Result<usize, String> {
679    fetch_and_apply_control_with(transport, community, Evidence::Quorum).await
680}
681
682/// [`fetch_and_apply_control`] at Full evidence — for callers whose folded view
683/// gates a DESTRUCTIVE decision (the pre-admin-write sync: its `is_public` read
684/// routes a ban through the member-severing read-cut path, so it must see the
685/// completest control plane the reachable relays allow).
686pub async fn fetch_and_apply_control_full<T: Transport + ?Sized>(
687    transport: &T,
688    community: &Community,
689) -> Result<usize, String> {
690    fetch_and_apply_control_with(transport, community, Evidence::Full).await
691}
692
693async fn fetch_and_apply_control_with<T: Transport + ?Sized>(
694    transport: &T,
695    community: &Community,
696    evidence: Evidence,
697) -> Result<usize, String> {
698    crate::db::scoped(async move {
699        let cid = community.id.to_hex();
700        // binary seal: once dissolved, the control fold STOPS advancing — no further editions apply (the
701        // inbound message path likewise drops everything). Cheap flag check before any fetch.
702        if crate::db::community::get_community_dissolved(&cid)? {
703            return Ok(0);
704        }
705        let folded = fetch_control_folded_with(transport, community, evidence).await?;
706        // tombstone: if a GroupDissolved edition at the locator was signed by the PROVEN owner (derived
707        // via the deed at fold time, never a cached field), SEAL the community and stop. Fail-closed: an
708        // unreadable deed (no proven owner) or a non-owner signer is REJECTED — we stay in the prior state,
709        // never death-by-default. THIS fold pass IS the "one bounded final drain": the banlist/roles/
710        // metadata applied below are the last accepted control; subsequent syncs see the flag and drop.
711        // Detect an owner tombstone via EITHER the rotation-stable coordinate probe (the cross-epoch path: a
712        // post-rotation joiner only derives a later root + never fetches the publish-epoch control_pseudonym,
713        // but always derives `dissolved_pseudonym`) OR the control-plane fold (the current-epoch fast path).
714        // Owner derived from the deed at fold time; fail-closed (no proven owner / non-owner signer ⇒ rejected).
715        if let Some(owner) = proven_owner_hex(community) {
716            let by_fold = folded.dissolved_by.iter().any(|s| s.to_hex() == owner);
717            let probe_records = if by_fold {
718                Vec::new()
719            } else {
720                dissolved_tombstone_records(transport, community).await
721            };
722            let by_probe = !by_fold && probe_records.iter().any(|d| d.author.to_hex() == owner);
723            if by_fold || by_probe {
724                // v1→v2 migration: extract + persist the pointer BEFORE the seal. The seal
725                // short-circuits every future control fetch for this community, so this fold is
726                // the payload's one guaranteed ride on a live client (the boot sweep re-probes
727                // for anyone who sealed on an older build). Selection is total and payload-aware:
728                // a plain `{}` tombstone seals but never sheds an already-published pointer.
729                let mut tombstones = folded.dissolved_editions.clone();
730                tombstones.extend(probe_records);
731                let mut migration_pointer_found = false;
732                if let Some((_, raw)) = super::migration::select_pointer(&tombstones, &owner) {
733                    let _ = crate::db::community::set_migration_pointer(&cid, &raw);
734                    migration_pointer_found = true;
735                }
736                // This fold pass IS the "one bounded final drain": apply the last accepted control, then
737                // seal. Subsequent syncs short-circuit on the flag above and drop everything.
738                let _ = fetch_and_apply_banlist_inner(transport, community, Some(folded.clone())).await;
739                let _ = fetch_and_apply_roles_inner(transport, community, Some(folded.clone())).await;
740                let _ = fetch_and_apply_invite_links_inner(transport, community, Some(folded.clone())).await;
741                let _ = fetch_and_apply_metadata_inner(transport, community, Some(folded.clone())).await;
742                crate::db::community::set_community_dissolved(&cid)?;
743                // Notify the UI to re-render the dead community live (lock composer + end divider). Emitting
744                // from the single seal point covers EVERY caller — sync, boot, realtime refresh — not just the
745                // realtime path. Fires once: the short-circuit above skips it on every subsequent fetch.
746                crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid }));
747                // A migration carrier drives the flip RIGHT HERE — the moment the member folds it.
748                // Post-seal is safe: `drive_migration`'s exemption lets a stale root still walk, and
749                // the whole drive is idempotent + retried by the boot maintenance, so a transient
750                // failure (relay flake mid-join) is never terminal. Best-effort by design.
751                if migration_pointer_found {
752                    match Box::pin(super::migration::drive_migration(transport, community)).await {
753                        Ok(Some(v2_hex)) => super::migration::spawn_finalize_migration(cid.clone(), v2_hex),
754                        Ok(None) => {}
755                        Err(e) => crate::log_warn!("migration drive for {cid}: {e}"),
756                    }
757                }
758                return Ok(folded.fetched);
759            }
760        }
761        // Openable control editions this single fetch served — the caller's "≥1 relay returned our actual plane"
762        // isolation signal (no separate probe fetch needed).
763        let fetched = folded.fetched;
764        let _ = fetch_and_apply_banlist_inner(transport, community, Some(folded.clone())).await;
765        let _ = fetch_and_apply_roles_inner(transport, community, Some(folded.clone())).await;
766        let _ = fetch_and_apply_invite_links_inner(transport, community, Some(folded.clone())).await;
767        let _ = fetch_and_apply_metadata_inner(transport, community, Some(folded)).await;
768        Ok(fetched)
769    })
770    .await
771}
772
773pub async fn fetch_and_apply_banlist<T: Transport + ?Sized>(
774    transport: &T,
775    community: &Community,
776) -> Result<Vec<String>, String> {
777    fetch_and_apply_banlist_inner(transport, community, None).await
778}
779
780async fn fetch_and_apply_banlist_inner<T: Transport + ?Sized>(
781    transport: &T,
782    community: &Community,
783    prefolded: Option<super::roster::FoldedRoster>,
784) -> Result<Vec<String>, String> {
785    crate::db::scoped(async move {
786        let cid = community.id.to_hex();
787        let folded = match prefolded {
788            Some(f) => f,
789            None => fetch_control_folded(transport, community).await?,
790        };
791        // Authority: the banlist signer must hold BAN in the AUTHORIZED roster (delegation-chain filtered),
792        // not merely be validly-signed. A demoted/never-authorized signer's banlist is dropped.
793        let owner = proven_owner_hex(community);
794        let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
795        if let (Some(author), Some(head)) = (folded.banlist_author, &folded.banlist_head) {
796            // Authority is per-target, not just the BAN bit: the signer must strictly OUTRANK every member
797            // in the delta between the list we hold and the folded list (both newly-banned and newly-unbanned)
798            // — the same check the sender ran. A bit-only check would let a low-ranked BAN-holder ban or
799            // unban a peer/superior (or the owner). Owner is never a valid target (folds out of can_act_on_member).
800            let author_hex = author.to_hex();
801            let held: std::collections::HashSet<String> =
802                crate::db::community::get_community_banlist(&cid)?.into_iter().collect();
803            let next: std::collections::HashSet<&str> = folded.banned.iter().map(|s| s.as_str()).collect();
804            let added = folded.banned.iter().filter(|n| !held.contains(n.as_str()));
805            let removed = held.iter().filter(|n| !next.contains(n.as_str()));
806            // version-pinned authority: the banner's edition cites the grant that authorizes them; we
807            // apply only if we have folded that grant to AT LEAST the cited version (a complete, un-forked
808            // view — else fail closed, never act on a partial authority view). The per-target outrank below
809            // is then resolved against the CURRENT authorized roster, so a since-demoted banner is dropped
810            // there (refuse-superseded). Owner cites nothing and is supreme.
811            let citation = folded.banlist_head.as_ref().and_then(|h| h.citation.as_ref());
812            let banner_grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(&community.id, &author.to_bytes()));
813            let pinned = super::roster::authority_citation_satisfied(&folded.heads, owner.as_deref(), &author_hex, &banner_grant_hex, citation);
814            let authed = pinned
815                && added.chain(removed).all(|target| {
816                    authorized.can_act_on_member(&author_hex, owner.as_deref(), target, super::roles::Permissions::BAN)
817                });
818            let held_version = crate::db::community::get_edition_head(&cid, &head.entity_hex)?.map(|(v, _)| v).unwrap_or(0);
819            if authed && head.version > held_version {
820                crate::db::community::set_community_banlist(&cid, &folded.banned, head.version as i64)?;
821                crate::db::community::set_edition_head(&cid, &head.entity_hex, head.version, &head.self_hash)?;
822                return Ok(folded.banned);
823            }
824        }
825        // Nothing newer/authorized applied — report the banlist we still hold, not an empty list.
826        crate::db::community::get_community_banlist(&cid)
827    })
828    .await
829}
830
831/// Set a member's complete role set (owner/admin authority) and publish their per-member
832/// Grant event (vsk=3). Empty `role_ids` revokes all of that member's roles. Persists the updated
833/// local graph BEFORE the publish await (so our own client reflects it immediately and the write
834/// lands in the captured account); the relay echo dedups.
835pub async fn set_member_grant<T: Transport + ?Sized>(
836    transport: &T,
837    community: &Community,
838    member_hex: &str,
839    role_ids: Vec<String>,
840) -> Result<(), String> {
841    crate::db::scoped(async move {
842        // Keyless model: the grant is a real-npub-signed edition. Sign
843        // with the actor's own identity via the active signer (local vault OR a NIP-46 bunker).
844        let signer = crate::signer::active_signer()?;
845        let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the grant edition")?;
846        let cid = community.id.to_hex();
847        let grant = super::roles::MemberGrant { member: member_hex.to_string(), role_ids };
848
849        // Next version in this member's grant chain. The entity coordinate is the member's grant locator,
850        // so the head tracks per-member; v+1 cites the held head's self_hash (genesis v1 if none).
851        let member_bytes = crate::simd::hex::hex_to_bytes_32(member_hex);
852        let entity_id = super::derive::grant_locator(&community.id, &member_bytes);
853        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
854        let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
855            Some((v, h)) => (v + 1, Some(h)),
856            None => (1, None),
857        };
858        let created_at = web_time::SystemTime::now()
859            .duration_since(web_time::UNIX_EPOCH)
860            .map(|d| d.as_secs())
861            .unwrap_or(0);
862
863        // Build (real-npub signed inner) + seal under the server-root for the wire. The grant authoring
864        // gate (`caller_can_manage_role`) runs in the grant_role/revoke_role callers; this is the encoder.
865        // pinned authority: a delegated admin granting a lower member cites the grant that authorizes
866        // them, so the delegation chain is verifiable at that version. The owner cites nothing (supreme).
867        // (Owner-only granting is the MVP norm, so this is usually `None` — but emitting it now keeps the
868        // immutable wire data complete for the delegation-chain verifier, rather than baking in a gap.)
869        let citation = authority_citation(community, &actor_pk.to_hex());
870        let unsigned = super::roster::build_grant_edition_unsigned(actor_pk, &community.id, &grant, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
871        let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign grant edition: {e}"))?;
872        let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
873        // The new head's self_hash = a hash over the EXACT content bytes the inner committed to (not a
874        // re-serialization), so the stored head matches the published edition and the next edition's
875        // prev_hash cites it correctly.
876        let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
877
878        let is_full_revoke = grant.role_ids.is_empty();
879        // Compute the advanced local state in memory (cheap; no DB write yet).
880        let mut roster = crate::db::community::get_community_roles(&cid)?;
881        roster.grants.retain(|g| g.member != member_hex);
882        if !grant.role_ids.is_empty() {
883            roster.grants.push(grant);
884        }
885
886        // Publish FIRST, then persist the advanced head + roster only on success. Advancing the head
887        // before a fallible publish would leave a phantom head: a failed publish means the next edition
888        // cites an unpublished predecessor, which every fold quarantines as a gap forever. Re-check the
889        // session after the await — it may have straddled an account swap, and persisting then would
890        // write into the wrong account (the edition published under the captured one).
891        transport.publish_durable(&outer, &community.relays).await?;
892        crate::db::community::set_community_roles(&cid, &roster, created_at as i64)?;
893        crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
894
895        // Revoke-time re-assert (publish-time authority — "Concord Convergence"): a demotion drops the
896        // member's authority, so the author-aware fold would orphan any authority-gated entity the member
897        // currently HEADS. Re-publish those heads as the actor (the `republish_*` helpers gate on the actor's
898        // own permission), so the member's validly-published content survives for EVERY client — fresh joiners
899        // included — and a post-demotion forgery can't win. Skip-if-not-head: only entities the member actually
900        // heads are re-asserted (the common case publishes nothing). Best-effort + per-entity publish-then-
901        // persist inside the helpers (W2). MVP: full revoke only (`role_ids` empty); partial demote is a follow-on.
902        if is_full_revoke {
903            if let Ok(folded) = fetch_control_folded(transport, community).await {
904                let current = crate::db::community::load_community(&community.id)?.unwrap_or_else(|| community.clone());
905                if folded.root_author.map(|a| a.to_hex()).as_deref() == Some(member_hex) {
906                    if let Some(meta) = &folded.root_meta {
907                        let mut c = current.clone();
908                        c.name = meta.name.clone();
909                        c.description = meta.description.clone();
910                        c.icon = meta.icon.clone();
911                        c.banner = meta.banner.clone();
912                        let _ = republish_community_metadata(transport, &c).await;
913                    }
914                }
915                for cm in &folded.channel_meta {
916                    if cm.author.to_hex() == member_hex
917                        && current.channels.iter().any(|ch| ch.id.0 == cm.channel_id)
918                    {
919                        let _ = republish_channel_metadata(
920                            transport, &current, &crate::community::ChannelId(cm.channel_id), &cm.meta.name,
921                        ).await;
922                    }
923                }
924            }
925        }
926        Ok(())
927    })
928    .await
929}
930
931/// True iff the local user is the PROVEN owner of this community — derived by verifying the owner
932/// attestation against `my_public_key()` (keyless: the owner is the npub that signed the attestation
933/// binding this community_id). The check honest clients use to gate
934/// owner-only actions (mint invites, set images) and to render the owner crown.
935pub fn is_proven_owner(community: &Community) -> bool {
936    match crate::state::my_public_key() {
937        Some(me) => proven_owner_hex(community).as_deref() == Some(me.to_hex().as_str()),
938        None => false,
939    }
940}
941
942/// True iff the local user may manage roles — i.e. holds the `MANAGE_ROLES` permission.
943/// Permission-based, NOT a hardcoded owner check: the owner is simply the uppermost role and holds
944/// every permission; any member granted a role carrying `MANAGE_ROLES` qualifies just the same.
945pub fn caller_can_manage_roles(community: &Community) -> bool {
946    let me = match crate::state::my_public_key() {
947        Some(p) => p,
948        None => return false,
949    };
950    let cid = community.id.to_hex();
951    let is_owner = community
952        .owner_attestation
953        .as_ref()
954        .and_then(|a| super::owner::verify_owner_attestation(a, &cid))
955        .map(|pk| pk == me)
956        .unwrap_or(false);
957    if is_owner {
958        return true; // the uppermost role holds all permissions
959    }
960    crate::db::community::get_community_roles(&cid)
961        .unwrap_or_default()
962        .has_permission(&me.to_hex(), super::roles::Permissions::MANAGE_ROLES)
963}
964
965/// Does the local user hold `permission` in this community? The generalized [`caller_can_manage_roles`]:
966/// owner = supreme (every bit), otherwise the union of their granted roles' bits (the role engine).
967/// Drives both the capability report and the producer-side authority gates — no hardcoded owner check.
968pub fn caller_has_permission(community: &Community, permission: u64) -> bool {
969    let me = match crate::state::my_public_key() {
970        Some(p) => p,
971        None => return false,
972    };
973    crate::db::community::get_community_roles(&community.id.to_hex())
974        .unwrap_or_default()
975        .is_authorized(&me.to_hex(), proven_owner_hex(community).as_deref(), permission)
976}
977
978/// Can the local caller grant/revoke `role_id` — i.e. do they hold `MANAGE_ROLES` AND outrank that role's
979/// position? The crown's gate, expressed as the POSITION rule (NOT an owner check): the owner is just
980/// position 0, so in the single-@admin-role MVP this resolves to "owner only" because the @admin role sits
981/// directly below position 0 — but it generalizes to any role hierarchy. `false` if the role is unknown.
982pub fn caller_can_manage_role_id(community: &Community, role_id: &str) -> bool {
983    let me = match crate::state::my_public_key() {
984        Some(p) => p.to_hex(),
985        None => return false,
986    };
987    let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
988    let position = match roster.role(role_id) {
989        Some(r) => r.position,
990        None => return false,
991    };
992    roster.can_manage_position(&me, proven_owner_hex(community).as_deref(), position)
993}
994
995/// The local user's effective management capabilities in a community, resolved purely by the role engine
996/// (positions + permission bits; the owner is just the role at position 0 — NOTHING is owner-hardcoded).
997/// The frontend gates each management affordance on the matching bit, so an admin whose role carries a
998/// permission gets the exact same affordance as the owner.
999#[derive(Debug, Clone, Default, serde::Serialize)]
1000pub struct CommunityCapabilities {
1001    pub manage_metadata: bool,
1002    pub manage_channels: bool,
1003    pub create_invite: bool,
1004    pub kick: bool,
1005    pub ban: bool,
1006    pub manage_messages: bool,
1007    pub manage_roles: bool,
1008}
1009
1010pub fn caller_capabilities(community: &Community) -> CommunityCapabilities {
1011    use super::roles::Permissions as P;
1012    let me_hex = match crate::state::my_public_key() {
1013        Some(p) => p.to_hex(),
1014        None => return CommunityCapabilities::default(),
1015    };
1016    let owner = proven_owner_hex(community);
1017    let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
1018    let has = |bit: u64| roster.is_authorized(&me_hex, owner.as_deref(), bit);
1019    CommunityCapabilities {
1020        manage_metadata: has(P::MANAGE_METADATA),
1021        manage_channels: has(P::MANAGE_CHANNELS),
1022        create_invite: has(P::CREATE_INVITE),
1023        kick: has(P::KICK),
1024        ban: has(P::BAN),
1025        manage_messages: has(P::MANAGE_MESSAGES),
1026        manage_roles: has(P::MANAGE_ROLES),
1027    }
1028}
1029
1030/// The pinned authority citation the local user attaches to a control action — points at their
1031/// OWN authorizing Grant edition (stable community-scoped coordinate + its current head version/hash),
1032/// so every verifier resolves the action's authority against that exact point instead of their own
1033/// possibly-lagging-or-ahead live roster. `None` when the local user is the proven owner (supreme —
1034/// owner actions cite nothing) or has no grant head to cite (an unauthorized actor — the send-side
1035/// authority gate refuses them before a citation would matter). See
1036/// [`super::roster::authority_citation_satisfied`] for the verifier side.
1037fn authority_citation(community: &Community, actor_hex: &str) -> Option<super::edition::AuthorityCitation> {
1038    if proven_owner_hex(community).as_deref() == Some(actor_hex) {
1039        return None;
1040    }
1041    let cid = community.id.to_hex();
1042    let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
1043    let entity_id = super::derive::grant_locator(&community.id, &actor_bytes);
1044    let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
1045    crate::db::community::get_edition_head(&cid, &entity_hex)
1046        .ok()
1047        .flatten()
1048        .map(|(version, edition_hash)| super::edition::AuthorityCitation { entity_id, version, edition_hash })
1049}
1050
1051/// The proven owner's pubkey (hex), or `None` on an unproven community (no attestation / fails to
1052/// verify). The owner is DERIVED by verifying the attestation, never a bare claim.
1053pub(crate) fn proven_owner_hex(community: &Community) -> Option<String> {
1054    let cid = community.id.to_hex();
1055    community
1056        .owner_attestation
1057        .as_ref()
1058        .and_then(|a| super::owner::verify_owner_attestation(a, &cid))
1059        .map(|pk| pk.to_hex())
1060}
1061
1062/// Can `actor_hex` moderation-hide a message authored by `author_hex` in this community? True iff
1063/// the actor holds MANAGE_MESSAGES and strictly outranks the author (the owner is unhideable). This
1064/// is the SINGLE source of truth for moderation authority — both the publish gate
1065/// (`publish_owner_hide`) and the UI affordance (`get_message_delete_options`) call it, so the
1066/// button shown can never disagree with what the publish will actually allow.
1067pub fn can_moderation_hide(community: &Community, actor_hex: &str, author_hex: &str) -> bool {
1068    // The owner comes from the in-hand struct rather than a re-read, but everything after it is the
1069    // shared predicate — a v2 row loaded through this v1 struct carries no attestation, and resolving
1070    // its owner as None both strips the owner's supremacy and exposes them as a target.
1071    let owner = proven_owner_hex(community)
1072        .or_else(|| super::moderation::owner_hex(&community.id.to_hex()));
1073    let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
1074    super::moderation::can_hide(owner.as_deref(), &roster, actor_hex, author_hex)
1075}
1076
1077/// Rekey-plane authority with §6 banlist precedence: a positive authority
1078/// lookup can never honor a banned identity. The banlist and the grant-revoke
1079/// are SEPARATE editions a withholding relay can split — without this, a
1080/// since-banned admin whose revoke is withheld still ranks for rotations,
1081/// letting them race their own removal with a re-founding. Read failure
1082/// degrades to "not banned" (the roster gate still fails closed on its own
1083/// read failure); the owner is exempt (supreme, never a valid ban target).
1084fn rotator_is_authorized(
1085    cid: &str,
1086    roster: &super::roles::CommunityRoles,
1087    owner_hex: Option<&str>,
1088    rotator_hex: &str,
1089    permission: u64,
1090) -> bool {
1091    if owner_hex != Some(rotator_hex)
1092        && crate::db::community::get_community_banlist(cid)
1093            .unwrap_or_default()
1094            .iter()
1095            .any(|b| b == rotator_hex)
1096    {
1097        return false;
1098    }
1099    roster.is_authorized(rotator_hex, owner_hex, permission)
1100}
1101
1102/// escalation defense for an authoring action — may the local caller grant/revoke `role_id` on
1103/// `member_hex`? The caller must strictly outrank BOTH the role being changed AND the target member
1104/// (so they can't grant a role at/above their own rank, nor touch a superior member). The owner is
1105/// supreme. Returns a frontend-displayable error if refused. Peers re-run the same predicate on
1106/// receipt (Phase 2) — this is the local half of the same rule.
1107fn caller_can_manage_role(
1108    community: &Community,
1109    roster: &super::roles::CommunityRoles,
1110    role_id: &str,
1111    member_hex: &str,
1112) -> Result<(), String> {
1113    let me = crate::state::my_public_key().ok_or("no active identity")?.to_hex();
1114    let owner = proven_owner_hex(community);
1115    let owner_ref = owner.as_deref();
1116    let role = roster.role(role_id).ok_or("no such role")?;
1117    if !roster.can_manage_position(&me, owner_ref, role.position) {
1118        return Err("you can only manage roles below your own".to_string());
1119    }
1120    if !roster.can_manage_member(&me, owner_ref, member_hex) {
1121        return Err("you can't manage a member who outranks you".to_string());
1122    }
1123    Ok(())
1124}
1125
1126/// Grant `member` a role (requires the `MANAGE_ROLES` permission). Publishes the per-member Grant
1127/// event. The member already holds read keys from membership; the roster entry adds write authority,
1128/// exercised by signing their own control actions, which peers verify against the roster.
1129pub async fn grant_role<T: Transport + ?Sized>(
1130    transport: &T,
1131    community: &Community,
1132    member: nostr_sdk::prelude::PublicKey,
1133    role_id: &str,
1134) -> Result<(), String> {
1135    let cid = community.id.to_hex();
1136    let member_hex = member.to_hex();
1137    let roster = crate::db::community::get_community_roles(&cid)?;
1138    caller_can_manage_role(community, &roster, role_id, &member_hex)?;
1139    // The member's new full role set = existing + this role (deduped).
1140    let mut role_ids: Vec<String> = roster
1141        .grants
1142        .iter()
1143        .find(|g| g.member == member_hex)
1144        .map(|g| g.role_ids.clone())
1145        .unwrap_or_default();
1146    if !role_ids.iter().any(|r| r == role_id) {
1147        role_ids.push(role_id.to_string());
1148    }
1149
1150    // Keyless model: granting a role delivers NO secret. Authority is the grantee's npub being in
1151    // the roster at that rank — they exercise it by signing their own actions, which peers verify
1152    // against the roster.
1153    set_member_grant(transport, community, &member_hex, role_ids).await
1154}
1155
1156/// Revoke a role from `member` (owner/admin authority) — instant *logical* (the role record is
1157/// dropped, so the grant-set check stops honoring their actions). The *physical* lockout
1158/// (channel rekey per) is a later step; this only edits the grant. In the MVP a role is permission
1159/// bits, NOT a channel read key (channels aren't role-gated), so a revoke needs NO rekey and a bunker
1160/// account can do it freely. WHEN role-gated channels ship, the rekey-on-revoke path must adopt the same
1161/// bunker fail-fast guard as `publish_banlist`/`revoke_public_invite` (a rekey needs a raw local key).
1162pub async fn revoke_role<T: Transport + ?Sized>(
1163    transport: &T,
1164    community: &Community,
1165    member: nostr_sdk::prelude::PublicKey,
1166    role_id: &str,
1167) -> Result<(), String> {
1168    let cid = community.id.to_hex();
1169    let member_hex = member.to_hex();
1170    let roster = crate::db::community::get_community_roles(&cid)?;
1171    caller_can_manage_role(community, &roster, role_id, &member_hex)?;
1172    let role_ids: Vec<String> = roster
1173        .grants
1174        .iter()
1175        .find(|g| g.member == member_hex)
1176        .map(|g| g.role_ids.iter().filter(|r| r.as_str() != role_id).cloned().collect())
1177        .unwrap_or_default();
1178    set_member_grant(transport, community, &member_hex, role_ids).await
1179}
1180
1181/// Fetch the Community's role graph (real-npub control editions, kind 3308) and fold it into the
1182/// local roster. Fetches by the **server-root pseudonym** (not by author — the outer is
1183/// ephemeral), opens each edition under the server-root key, and folds: verify authorship, bind
1184/// entity↔content, version-fold, quarantine gaps. Advances each entity's monotonic head (the
1185/// per-entity refuse-downgrade floor) and refreshes the roster cache. Returns the folded roster.
1186pub async fn fetch_and_apply_roles<T: Transport + ?Sized>(
1187    transport: &T,
1188    community: &Community,
1189) -> Result<super::roles::CommunityRoles, String> {
1190    fetch_and_apply_roles_inner(transport, community, None).await
1191}
1192
1193async fn fetch_and_apply_roles_inner<T: Transport + ?Sized>(
1194    transport: &T,
1195    community: &Community,
1196    prefolded: Option<super::roster::FoldedRoster>,
1197) -> Result<super::roles::CommunityRoles, String> {
1198    crate::db::scoped(async move {
1199        let cid = community.id.to_hex();
1200        let folded = match prefolded {
1201            Some(f) => f,
1202            None => fetch_control_folded(transport, community).await?,
1203        };
1204
1205        // NOTE: `folded.gapped_entities` is not consumed yet — the fold is fail-closed by construction
1206        // (gapped heads are never folded into `folded.roles`), so it's safe in the single-writer MVP. Once
1207        // multi-writer + rotation ship, this must suspend any cached entry whose entity is now gapped.
1208        // Advance each entity's head MONOTONICALLY — the per-entity rollback defense (a withholding relay
1209        // serving only old editions can't lower a head; our own publish's echo is a no-op). The roster
1210        // CACHE is a derived view refreshed from the fold; a withholding relay can transiently shrink it,
1211        // but it self-heals on the next quorum fetch and the send side reads the (monotonic) heads, not
1212        // the cache. (`roles_at` is vestigial under the per-entity model — the heads are the floor now.)
1213        for head in &folded.heads {
1214            crate::db::community::set_edition_head(&cid, &head.entity_hex, head.version, &head.self_hash)?;
1215        }
1216        // Don't let an empty/withheld fetch wipe a populated roster cache: only refresh it when the fold
1217        // actually produced editions. The heads above already advanced monotonically (the real floor);
1218        // the cache is a derived view, so on an empty fold we return what we still hold. (Full per-entity
1219        // merge so a PARTIAL fetch can't shrink the cache either is the quorum/completeness work, G1.)
1220        if folded.heads.is_empty() {
1221            return crate::db::community::get_community_roles(&cid);
1222        }
1223        // Authorize: keep only entries whose SIGNER was allowed (delegation chain to the owner).
1224        // A validly-signed+bound-but-unauthorized edition (e.g. a self-signed Admin grant) is dropped here,
1225        // never cached as authority. Owner resolved from the (verified) attestation; unproven → empty.
1226        let authorized = super::roster::authorize_delegation(&folded, proven_owner_hex(community).as_deref());
1227        crate::db::community::set_community_roles(&cid, &authorized, 0)?;
1228        Ok(authorized)
1229    })
1230    .await
1231}
1232
1233/// Moderation-hide: publish a 3305 delete for another member's message, signed by the actor's
1234/// REAL npub (keyless). Authority is the inner signature, re-verified
1235/// by every member against the owner-rooted roster (MANAGE_MESSAGES + a strict outrank of the
1236/// target's author). Permanent (the tombstone can't be un-published).
1237pub async fn publish_owner_hide<T: Transport + ?Sized>(
1238    transport: &T,
1239    community: &Community,
1240    channel: &Channel,
1241    target_message_id: &str,
1242) -> Result<(), String> {
1243    // hierarchy gate (keyless): I must hold MANAGE_MESSAGES and strictly outrank the target
1244    // message's author — the owner, outranked by no one, can never be hidden. Resolve the author from
1245    // local state (you can only moderate a message you can see). A granted
1246    // MANAGE_MESSAGES member can moderate. Peers RE-verify this against my real-npub inner sig + roster.
1247    let signer = crate::signer::active_signer()?;
1248    let me_pk = crate::state::my_public_key().ok_or("no local identity to sign the hide")?;
1249    let me = me_pk.to_hex();
1250    {
1251        let target_author = {
1252            let st = crate::state::STATE.lock().await;
1253            st.find_message(target_message_id).and_then(|(_, m)| m.npub)
1254        };
1255        let author = target_author
1256            .ok_or("can't resolve the target message's author to authorize the hide")?;
1257        if !can_moderation_hide(community, &me, &author) {
1258            return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
1259        }
1260    }
1261    let ms = web_time::SystemTime::now()
1262        .duration_since(web_time::UNIX_EPOCH)
1263        .map(|d| d.as_millis() as u64)
1264        .unwrap_or(0);
1265    // Keyless moderation-hide: a 3305 delete signed by MY REAL npub. The inner signature IS the
1266    // authority proof — every member re-verifies it against
1267    // the roster, so authority is member-visible + non-repudiable, not anonymized.
1268    // pinned authority: a non-owner hider cites the grant that authorizes them, carried as a `vac`
1269    // tag on the inner so peers resolve the hide against that grant version (the owner cites nothing).
1270    let citation = authority_citation(community, &me);
1271    let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
1272    let inner = super::envelope::build_inner_full(
1273        me_pk, &channel.id, channel.epoch,
1274        event_kind::COMMUNITY_DELETE, "", ms, Some(target_message_id), &[], &extra,
1275    )
1276    .finalize_async(&signer)
1277    .await
1278    .map_err(|e| format!("sign hide: {e}"))?;
1279    let _ = publish_signed_message(transport, community, channel, &inner, true).await?;
1280    Ok(())
1281}
1282
1283/// Delete a message the local user previously sent, by its INNER message id (what the UI
1284/// holds). Loads the retained ephemeral key + the outer event id it points at, then
1285/// NIP-09-deletes that outer event. Errors if no key is retained (not ours, or already
1286/// deleted).
1287pub async fn delete_message<T: Transport + ?Sized>(
1288    transport: &T,
1289    message_id: &str,
1290) -> Result<(), String> {
1291    crate::db::scoped(async move {
1292        let session = crate::db::current_session();
1293        if !session.is_live() {
1294            return Err("account changed; aborting delete".to_string());
1295        }
1296        // PEEK the key (don't consume it yet): the NIP-09 publish below is fallible, and the
1297        // key is single-use — consuming it before a failed publish would leave the message
1298        // permanently undeletable. Remove it only after the deletion actually goes out.
1299        let (ephemeral, outer_event_id_hex, relays) = match crate::db::community::get_message_key(message_id)? {
1300            Some(v) => v,
1301            None => {
1302                return Err("no retained key for this message (not yours, or already deleted)".to_string())
1303            }
1304        };
1305        let id = EventId::from_hex(&outer_event_id_hex).map_err(|e| e.to_string())?;
1306        delete_own_message(transport, &relays, &ephemeral, id).await?;
1307        // Published — now it's safe to consume the key.
1308        crate::db::community::delete_message_key(message_id)?;
1309        Ok(())
1310    })
1311    .await
1312}
1313
1314/// Accept a parked invite and persist the member-view Community (the user-consented
1315/// half of the carrier — the inbound handler only *parks* invites; this is reached
1316/// from an explicit accept command). Guards against id-collision overwrites:
1317///
1318/// - if we already OWN a Community with this id, refuse (a member-view save would clobber
1319///   our owner state);
1320/// - if we already hold it as a member under a DIFFERENT server root, refuse —
1321///   `community_id` is unauthenticated random bytes, so a hostile bundle reusing
1322///   a known id must not be able to swap out our channel keys / authority / relays.
1323///
1324/// `std::sync::Arc<crate::db::Session>`-gated: the accept may straddle a relay-fetch in the caller, and the
1325/// save must land in the account that consented.
1326pub fn accept_invite(invite: &CommunityInvite) -> Result<Community, String> {
1327    let session = crate::db::current_session();
1328    let community = super::invite::accept_invite(invite)?; // validates caps + decodes keys
1329
1330    match crate::db::community::load_community(&community.id)? {
1331        // Already a member: a re-accept doesn't grow the list, so it's exempt from the cap.
1332        Some(existing) => {
1333            // Migration fence at the DOOR: this save's channel UPSERT blindly re-parents rows,
1334            // so a stale v1 invite redeemed after the flip would steal the stitched channels
1335            // back from the v2 twin. Gate BEFORE any persist, not just in finalize_member_join.
1336            if crate::db::community::get_migrated_to(&existing.id.to_hex())?.is_some() {
1337                return Err("This community has upgraded to Concord v2. Ask a member for a fresh invite.".to_string());
1338            }
1339            if is_proven_owner(&existing) {
1340                return Err("you already own this Community".to_string());
1341            }
1342            // A known community id arriving with a DIFFERENT base key is a different community wearing
1343            // the same id (collision / hijack) — reject rather than overwrite. The server-root key is
1344            // the community's core secret, so it's the keyless authority anchor.
1345            if existing.server_root_key.as_bytes() != community.server_root_key.as_bytes() {
1346                return Err(
1347                    "invite reuses a known Community id under a different authority — rejected"
1348                        .to_string(),
1349                );
1350            }
1351        }
1352        // New membership — reject if we're already at the local community cap.
1353        None => enforce_community_cap()?,
1354    }
1355
1356    if !session.is_live() {
1357        return Err("account changed during invite accept".to_string());
1358    }
1359    crate::db::community::save_community(&community)?;
1360    Ok(community)
1361}
1362
1363/// Warm a community's primary-channel first page into the RAM preload cache BEFORE the user joins,
1364/// so accepting opens a populated chat instead of paying the join sync. RAM-only and side-effect-
1365/// free: builds the member view from the bundle WITHOUT persisting (nothing is stored for a
1366/// community the user may decline), fetches one page, and stashes it keyed by community id (the
1367/// fetch also warms the relay connection). Best-effort — any failure just leaves Join to sync
1368/// normally. Spawn this behind a `std::sync::Arc<crate::db::Session>`; promotion on Join re-validates freshness.
1369pub async fn preload_community(invite: &super::invite::CommunityInvite) {
1370    let Ok(community) = super::invite::accept_invite(invite) else { return };
1371    let Some(channel) = community.channels.first() else { return };
1372    let cid = community.id.to_hex();
1373    // Mark in-flight FIRST so a Join that races the fetch adopts it instead of double-fetching.
1374    crate::community::cache::begin_preload(&cid);
1375    let transport = super::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1376    // Newest page, no `since` (first warm). 50 mirrors the GUI page limit.
1377    match super::send::fetch_channel_page(&transport, &community, channel, None, None, 50).await {
1378        Ok(page) if !page.is_empty() => crate::community::cache::finish_preload(&cid, page),
1379        // Empty page or fetch error → drop the in-flight marker so an adopter falls back at once.
1380        _ => crate::community::cache::abort_preload(&cid),
1381    }
1382
1383    // Warming this invite added its (≤5, capped) relays to the pool. If it never becomes a join
1384    // within the preload window, shed them — an unsolicited or declined invite must not park relays
1385    // in the pool forever (#297). A genuine Join re-warms them via its subscription, so this is safe.
1386    let prune_relays = community.relays.clone();
1387    let prune_id = community.id;
1388    crate::db::spawn_bound(async move {
1389        crate::rt::time::sleep(crate::community::cache::PRELOAD_TTL).await;
1390        // Joined within the window? Its relays are legitimate now (and its preload entry was already
1391        // taken on accept) — leave them.
1392        if matches!(crate::db::community::load_community(&prune_id), Ok(Some(_))) {
1393            return;
1394        }
1395        // Drop any lingering warm entry, then shed the relays no joined community needs.
1396        crate::community::cache::abort_preload(&prune_id.to_hex());
1397        super::transport::prune_unneeded_community_relays(&prune_relays).await;
1398    });
1399}
1400
1401/// Persist edited Community display metadata and republish the GroupRoot as a real-npub 3308 edition
1402/// (vsk=0) so other members + re-anchoring pick it up. Keyless authority: the actor must hold
1403/// `MANAGE_METADATA` (the owner holds every permission). The caller mutates `community` (name /
1404/// description / icon / banner) first; this gates, saves it, then publishes the next edition version.
1405pub async fn republish_community_metadata<T: Transport + ?Sized>(
1406    transport: &T,
1407    community: &Community,
1408) -> Result<(), String> {
1409    crate::db::scoped(async move {
1410        let cid = community.id.to_hex();
1411        // Migration fence: the success path saves the caller's v1 struct (blind channel UPSERT),
1412        // which would steal stitched rows back from the v2 twin. Refuse before publishing.
1413        if crate::db::community::get_migrated_to(&cid)?.is_some() {
1414            return Err("this community has upgraded to Concord v2".to_string());
1415        }
1416        let signer = crate::signer::active_signer()?;
1417        let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the metadata edition")?;
1418        let owner = proven_owner_hex(community);
1419        let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
1420        if !roster.is_authorized(&actor_pk.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_METADATA) {
1421            return Err("only a member with manage-metadata authority can edit the community".to_string());
1422        }
1423        // Publish-FIRST, then persist content + head on success (now that `fetch_and_apply_metadata` is a
1424        // live consumer, metadata is relay-authoritative: a failed publish must not leave us showing an edit
1425        // no member can see, and advancing the head before a fallible publish would phantom-head it — the
1426        // successor cites an unpublished predecessor → the fold quarantines the chain forever).
1427        let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &cid)? {
1428            Some((v, h)) => (v + 1, Some(h)),
1429            None => (1, None),
1430        };
1431        let created = web_time::SystemTime::now()
1432            .duration_since(web_time::UNIX_EPOCH)
1433            .map(|d| d.as_secs())
1434            .unwrap_or(0);
1435        let meta = super::metadata::CommunityMetadata::of(community);
1436        // authority citation — the actor's "role badge" (the grant they act under), emitted by EVERY other
1437        // control producer. Owner cites nothing (supreme). The metadata consumer doesn't version-pin on it (a
1438        // metadata edit is cosmetic + self-healing, unlike an access-cutting ban), but emitting it keeps the
1439        // immutable wire data complete rather than baking in a gap.
1440        let citation = authority_citation(community, &actor_pk.to_hex());
1441        let unsigned = super::roster::build_community_root_edition_unsigned(actor_pk, &community.id, &meta, version, prev_hash.as_ref(), created, citation.as_ref())?;
1442        let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign community-root edition: {e}"))?;
1443        let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1444        transport.publish_durable(&outer, &community.relays).await?;
1445        crate::db::community::save_community(community)?;
1446        let h = super::version::edition_hash(&community.id.0, version, prev_hash.as_ref(), inner.content.as_bytes());
1447        // Record OUR own edition's inner_id so a peer's same-version fork can't displace it unless that
1448        // peer genuinely wins the deterministic tiebreak (lower inner id), per converge_edition_head.
1449        crate::db::community::set_edition_head_with_id(&cid, &cid, version, &h, &inner.id.to_bytes())?;
1450        Ok(())
1451    })
1452    .await
1453}
1454
1455/// Rename a channel and republish its ChannelMetadata as a real-npub 3308 edition (vsk=2) so
1456/// members fold it via [`fetch_and_apply_metadata`]. Keyless authority: the actor must hold
1457/// `MANAGE_CHANNELS` (channel edits are a channel-management action; the owner holds every permission).
1458/// `channel_id` must be one of `community`'s channels. Publish-FIRST then persist on success (relay-
1459/// authoritative, phantom-head-safe — same contract as the community GroupRoot).
1460pub async fn republish_channel_metadata<T: Transport + ?Sized>(
1461    transport: &T,
1462    community: &Community,
1463    channel_id: &crate::community::ChannelId,
1464    new_name: &str,
1465) -> Result<(), String> {
1466    crate::db::scoped(async move {
1467        let cid = community.id.to_hex();
1468        let ch_hex = channel_id.to_hex();
1469        // Migration fence: same door-gate as republish_community_metadata (the save re-parents rows).
1470        if crate::db::community::get_migrated_to(&cid)?.is_some() {
1471            return Err("this community has upgraded to Concord v2".to_string());
1472        }
1473        if !community.channels.iter().any(|c| &c.id == channel_id) {
1474            return Err("no such channel in this community".to_string());
1475        }
1476        let signer = crate::signer::active_signer()?;
1477        let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the channel metadata edition")?;
1478        let owner = proven_owner_hex(community);
1479        let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
1480        if !roster.is_authorized(&actor_pk.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
1481            return Err("only a member with manage-channels authority can rename a channel".to_string());
1482        }
1483        let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &ch_hex)? {
1484            Some((v, h)) => (v + 1, Some(h)),
1485            None => (1, None),
1486        };
1487        let created = web_time::SystemTime::now()
1488            .duration_since(web_time::UNIX_EPOCH)
1489            .map(|d| d.as_secs())
1490            .unwrap_or(0);
1491        let meta = super::metadata::ChannelMetadata { name: new_name.to_string() };
1492        // authority citation — same "role badge" the community-root + grant/ban producers emit (owner cites
1493        // nothing). Consumer doesn't version-pin metadata, but the wire data stays complete.
1494        let citation = authority_citation(community, &actor_pk.to_hex());
1495        let unsigned = super::roster::build_channel_metadata_edition_unsigned(actor_pk, channel_id, &meta, version, prev_hash.as_ref(), created, citation.as_ref())?;
1496        let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign channel-metadata edition: {e}"))?;
1497        let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1498        transport.publish_durable(&outer, &community.relays).await?;
1499        let mut current = crate::db::community::load_community(&community.id)?.ok_or("community no longer present")?;
1500        if let Some(ch) = current.channels.iter_mut().find(|c| &c.id == channel_id) {
1501            ch.name = new_name.to_string();
1502        }
1503        crate::db::community::save_community(&current)?;
1504        let h = super::version::edition_hash(&channel_id.0, version, prev_hash.as_ref(), inner.content.as_bytes());
1505        crate::db::community::set_edition_head_with_id(&cid, &ch_hex, version, &h, &inner.id.to_bytes())?;
1506        Ok(())
1507    })
1508    .await
1509}
1510
1511// ============================================================================
1512// Public (link) invites
1513// ============================================================================
1514
1515/// Mint a public invite link for a Community the local user owns: snapshot its preview,
1516/// build + publish the token-encrypted bundle to the Community relays, retain the token
1517/// locally (for list/revoke), and return `(hex token, shareable URL)`.
1518///
1519/// Owner-only: the bundle grants the @everyone base (server-root) key, and minting the
1520/// canonical link is an owner action. `std::sync::Arc<crate::db::Session>`-gated around the token persist.
1521/// A short, human-typable label for an unlabeled invite link. Crockford-ish base32 (no 0/1/I/O)
1522/// so it's unambiguous to read and share aloud; 6 chars ≈ 1B combinations (collision-improbable).
1523fn generate_invite_label() -> String {
1524    use rand::Rng;
1525    const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
1526    let mut rng = rand::thread_rng();
1527    (0..6).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect()
1528}
1529
1530pub async fn create_public_invite<T: Transport + ?Sized>(
1531    transport: &T,
1532    community: &Community,
1533    expires_at: Option<u64>,
1534    label: Option<String>,
1535) -> Result<(String, String), String> {
1536    crate::db::scoped(async move {
1537        if !caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1538            return Err("you need the create-invite permission to mint a public invite".to_string());
1539        }
1540
1541        // Every link gets a label: use the one provided, else mint a random 6-char handle. A stable label
1542        // makes the link identifiable in the UI and keys per-link join attribution off (creator, label),
1543        // so it must be unique among THIS creator's links (else two links share a join bucket).
1544        let existing = crate::db::community::list_public_invites(&community.id.to_hex()).unwrap_or_default();
1545        let label_taken = |cand: &str| {
1546            existing.iter().any(|r| r.label.as_deref().map(|e| e.eq_ignore_ascii_case(cand)).unwrap_or(false))
1547        };
1548        let label = match label {
1549            Some(l) if !l.trim().is_empty() => {
1550                let l = l.trim().to_string();
1551                if label_taken(&l) {
1552                    return Err(format!("You already have an invite link labeled \u{201c}{l}\u{201d}. Pick a different label."));
1553                }
1554                Some(l)
1555            }
1556            // Random handle — regenerate on the (astronomically unlikely) collision.
1557            _ => {
1558                let mut l = generate_invite_label();
1559                while label_taken(&l) {
1560                    l = generate_invite_label();
1561                }
1562                Some(l)
1563            }
1564        };
1565
1566        // Attribution (metrics): stamp the bundle with who minted it (my npub) + the creator's label, so
1567        // a joiner's Presence can announce "invited by me via <label>".
1568        let creator_npub = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok());
1569        let token = public_invite::new_token();
1570        let event = build_public_invite_event(community, &token, expires_at, creator_npub, label.clone()).map_err(|e| e.to_string())?;
1571        transport.publish_durable(&event, &community.relays).await?;
1572
1573        let token_hex = crate::simd::hex::bytes_to_hex_32(&token);
1574        let url = public_invite::encode_invite_url(&community.relays, &token);
1575        crate::db::community::save_public_invite(
1576            &token_hex,
1577            &community.id.to_hex(),
1578            &url,
1579            expires_at.map(|e| e as i64),
1580            label.as_deref(),
1581        )?;
1582        // Record the token in the self-encrypted Invite List so our other devices can see + copy + revoke this
1583        // link (the local token store is device-only). Sibling to the Community List, debounced republish.
1584        super::invite_list::add_invite(super::invite_list::InviteEntry {
1585            token: token_hex.clone(),
1586            community_id: community.id.to_hex(),
1587            url: url.clone(),
1588            label: label.clone(),
1589            created_at: web_time::SystemTime::now()
1590                .duration_since(web_time::UNIX_EPOCH)
1591                .map(|d| d.as_secs())
1592                .unwrap_or(0),
1593            expires_at,
1594        });
1595        // Publish MY updated invite-link set so every member's computed mode flips to Public — the link
1596        // now exists in the signed, foldable per-creator source of truth, not just my local token store.
1597        republish_my_invite_links(transport, community).await?;
1598        Ok((token_hex, url))
1599    })
1600    .await
1601}
1602
1603/// Read-only freshen for an invite preview: build the bundle's ephemeral community, fold the live
1604/// control plane, and return the LATEST authorized display metadata — never the bundle's mint-time
1605/// snapshot (which goes stale the moment metadata is edited; mirrors the website preview). No DB
1606/// floors and no persistence: the previewer isn't a member, so there is no local state to anchor.
1607/// Any failure falls back to the snapshot so a flaky relay can't blank the preview.
1608pub async fn latest_invite_preview<T: Transport + ?Sized>(
1609    transport: &T,
1610    bundle: &public_invite::PublicInviteBundle,
1611) -> public_invite::PublicInvitePreview {
1612    let snapshot = bundle.preview.clone();
1613    let Ok(community) = super::invite::accept_invite(&bundle.join) else {
1614        return snapshot;
1615    };
1616    let Ok(folded) = fetch_control_folded(transport, &community).await else {
1617        return snapshot;
1618    };
1619    let owner = proven_owner_hex(&community);
1620    let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
1621    match folded.root_candidates.iter().find(|c| {
1622        authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_METADATA)
1623    }) {
1624        Some(c) => public_invite::PublicInvitePreview {
1625            name: c.meta.name.clone(),
1626            description: c.meta.description.clone(),
1627            icon: c.meta.icon.clone(),
1628        },
1629        None => snapshot,
1630    }
1631}
1632
1633/// Fetch + decrypt the bundle for a public-invite token from the given bootstrap relays.
1634/// Queries the addressable coordinate (`d` = token locator, author = token signer) and
1635/// verifies the signer, so an impostor squatting the locator is rejected.
1636pub async fn fetch_public_invite<T: Transport + ?Sized>(
1637    transport: &T,
1638    relays: &[String],
1639    token: &[u8; 32],
1640) -> Result<PublicInviteBundle, String> {
1641    // Query by coordinate (kind + locator d-tag) only — do NOT rely on the relay to
1642    // honor an authors filter. A hostile relay can pile junk events at the same locator
1643    // (signed by other keys, possibly with a newer created_at to shadow the real one).
1644    let query = Query {
1645        kinds: vec![event_kind::APPLICATION_SPECIFIC],
1646        d_tags: vec![locator_hex(token)],
1647        ..Default::default()
1648    };
1649    let events = transport.fetch(&query, relays).await?;
1650    // Resolve by the NEWEST token-signed event at the coordinate (replaceable-event semantics), skipping any
1651    // impostor/junk (parse enforces author == token signer). A revocation tombstone is unforgeable, so a
1652    // `Revoked` verdict on ANY relay is authoritative — and it WINS ties with a bundle (fail-safe: a
1653    // deliberate revoke beats a same-second bundle), defeating the mixed-relay race where one relay kept the
1654    // stale live bundle. A genuinely re-created link (a bundle STRICTLY newer than the tombstone) still wins.
1655    let (mut bundle_at, mut bundle, mut revoked_at) = (0u64, None, None::<u64>);
1656    for ev in &events {
1657        match parse_public_invite_event(ev, token) {
1658            Ok(b) => if bundle.is_none() || ev.created_at.as_secs() > bundle_at {
1659                bundle_at = ev.created_at.as_secs();
1660                bundle = Some(b);
1661            },
1662            Err(super::public_invite::PublicInviteError::Revoked) => {
1663                let at = ev.created_at.as_secs();
1664                if revoked_at.is_none_or(|r| at > r) { revoked_at = Some(at); }
1665            }
1666            Err(_) => {} // impostor / junk / undecryptable — ignore
1667        }
1668    }
1669    match (bundle, revoked_at) {
1670        (Some(b), Some(r)) if bundle_at > r => Ok(b), // a re-created bundle strictly newer than the tombstone
1671        (_, Some(_)) => Err("this invite was revoked".to_string()),
1672        (Some(b), None) => Ok(b),
1673        (None, None) => Err("no public invite found at that link (revoked, never posted, or shadowed)".to_string()),
1674    }
1675}
1676
1677/// Accept a fetched public-invite bundle: reject if expired, join via the guarded
1678/// member-save (caps + id-collision checks), then patch in the preview's display
1679/// metadata (description/icon) so the new member sees them immediately.
1680pub fn accept_public_invite(bundle: &PublicInviteBundle, now_secs: u64) -> Result<Community, String> {
1681    if bundle.is_expired(now_secs) {
1682        return Err("this invite link has expired".to_string());
1683    }
1684    let mut community = accept_invite(&bundle.join)?;
1685    // accept_invite leaves display metadata None; the public bundle carries a preview,
1686    // so populate it (and re-save) for an immediately-rich member view.
1687    if bundle.preview.description.is_some() || bundle.preview.icon.is_some() {
1688        community.description = bundle.preview.description.clone();
1689        community.icon = bundle.preview.icon.clone();
1690        crate::db::community::save_community(&community)?;
1691    }
1692    Ok(community)
1693}
1694
1695/// Revoke a public invite: NIP-09-delete the bundle event (by its addressable coordinate, signed by the
1696/// token-derived key we re-derive from the retained token), forget the token locally, and republish the
1697/// invite-link registry so the mode tracks reality. **If this was the LAST link, the community goes
1698/// Private → it is re-founded (privatize): the base key is rotated to the observed-participants set,
1699/// sealing out link-joined lurkers who never spoke.** Creator-only: you can only retire YOUR OWN
1700/// links (the token is held only by its creator); the privatize rekey is `BAN`-gated + needs a local key.
1701pub async fn revoke_public_invite<T: Transport + ?Sized>(
1702    transport: &T,
1703    community: &Community,
1704    token: &[u8; 32],
1705) -> Result<(), String> {
1706    crate::db::scoped(async move {
1707        let cid = community.id.to_hex();
1708        let token_hex = crate::simd::hex::bytes_to_hex_32(token);
1709        let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1710        // Idempotent no-op if we don't hold the token: either it's already retired (re-revoke) or it's not
1711        // ours — creator-only, the token is held only by its creator. Nothing to do, never a double-rotate.
1712        if !crate::db::community::list_public_invites(&cid)?.iter().any(|r| r.token == token_hex) {
1713            return Ok(());
1714        }
1715        let my_locators_before: Vec<String> = crate::db::community::list_public_invites(&cid)?
1716            .iter()
1717            .filter(|r| r.expires_at.is_none_or(|e| (e as u64) > now))
1718            .map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
1719            .collect();
1720        // B1 fix: refresh the aggregate from relays FIRST, so the privatize decision sees OTHER creators'
1721        // live links (a stale/scroll-back-only cache would wrongly read empty and rekey a still-Public
1722        // community out from under another creator). Best-effort; on failure we fall back to the cache.
1723        let _ = fetch_and_apply_invite_links(transport, community).await;
1724        // Will retiring this link empty the AGGREGATE (this creator's remaining ∪ every other creator's)?
1725        // Others' locators = the freshly-folded aggregate minus mine (locators are per-token-unique). Only
1726        // then does it privatize → re-found rekey. Fail-fast (bunker): the rekey needs a RAW local key
1727        // (the blob locator is an ECDH a NIP-46 bunker can't expose) — refuse BEFORE publishing so we never
1728        // half-apply (flip to Private over a live base key). A community admin with a local key privatizes.
1729        let this_locator = public_invite::locator_hex(token);
1730        let cached_aggregate: std::collections::BTreeSet<String> =
1731            crate::db::community::get_community_invite_registry(&cid)?.into_iter().collect();
1732        let my_before: std::collections::BTreeSet<String> = my_locators_before.iter().cloned().collect();
1733        let others: std::collections::BTreeSet<String> = cached_aggregate.difference(&my_before).cloned().collect();
1734        let my_after: std::collections::BTreeSet<String> =
1735            my_before.iter().filter(|l| **l != this_locator).cloned().collect();
1736        let would_empty_aggregate = others.is_empty() && my_after.is_empty();
1737        if would_empty_aggregate && crate::state::MY_SECRET_KEY.to_keys().is_none() {
1738            return Err("Revoking this last invite link makes the community private, which re-keys it so link-joined lurkers lose access. Your account signs remotely (a NIP-46 bunker) and can't perform that rotation. Ask a community admin who holds a local key to privatize the community.".to_string());
1739        }
1740        // Revoke the bundle by OVERWRITING it with an empty, token-signed revocation tombstone (vsk=9) at its
1741        // coordinate. The bundle is a replaceable event (kind 30078), and relays honor replaceable-event
1742        // REPLACEMENT near-universally — far more reliably than NIP-09 `a`-tag (coordinate) deletions, which
1743        // many relays silently ignore (live-confirmed: 2 of 3 relays kept the bundle after a coordinate delete,
1744        // but all 3 replaced it with the tombstone). So the tombstone alone reliably kills the live bundle on
1745        // every relay AND leaves an explicit marker the preview page reads as "revoked". A NIP-09 delete is not
1746        // just redundant but counterproductive: on a relay that honors it, a same-second delete can drop the
1747        // tombstone too, leaving the coordinate empty and losing the revoked marker. (Not the access cut — the
1748        // rekey below is.) Best-effort so a publish hiccup can't block the rekey; publish_durable retries.
1749        if let Ok(tombstone) = public_invite::build_public_invite_tombstone(token) {
1750            let _ = transport.publish_durable(&tombstone, &community.relays).await;
1751        }
1752        crate::db::community::delete_public_invite(&token_hex)?;
1753        // Tombstone it in the self-encrypted Invite List so our other devices drop the link too (and a stale
1754        // device can't resurrect it). Terminal: a token is never re-minted.
1755        super::invite_list::revoke_invite(&token_hex, &cid);
1756        // Republish MY (reduced) link set so the mode reflects the removal, then set the recomputed aggregate.
1757        republish_my_invite_links(transport, community).await?;
1758        let aggregate_after: Vec<String> = others.union(&my_after).cloned().collect();
1759        crate::db::community::set_community_invite_registry(&cid, &aggregate_after)?;
1760        if would_empty_aggregate {
1761            // Aggregate empty → a genuine Public→Private transition → re-found (re-seal base to observed).
1762            // Durable (read_cut_pending): a failed privatize re-seal is resumed on the next ban or sync, like a
1763            // ban read-cut — not silently dropped, which would leave it half-private.
1764            run_read_cut(transport, community, true).await?;
1765        }
1766        Ok(())
1767    })
1768    .await
1769}
1770
1771/// owner dissolution ("Delete Community") — publish the terminal GroupDissolved tombstone, then seal
1772/// locally. The owner's ONLY honest exit (a bare leave would orphan the chain root). Order (defense in
1773/// depth): (a) authority — the caller MUST be the proven owner (a BAN admin is NOT enough — ending the
1774/// community for everyone is the owner's call alone); (b) publish the tombstone at `dissolved_locator`
1775/// FIRST and require it to LAND (must-succeed durable publish — a failed tombstone after a link-retire is a
1776/// stuck half-state); (c) THEN best-effort retire all of the owner's OWN public invite-link editions on a
1777/// path that emits NO 3303 rekey and NO epoch bump (dissolution rotates nothing — there is no future
1778/// content to protect); (d) set the local seal. Irreversible.
1779/// Probe the ROTATION-STABLE dissolved coordinate for a tombstone signed by `owner_hex`. The
1780/// cross-epoch discovery path: it fetches `dissolved_pseudonym` (community-id-derived, epoch-free) and
1781/// opens under the community-id envelope key, so a client holding ANY epoch root finds it. Best-effort
1782/// (a relay miss ⇒ false; the next sync re-probes). The caller has already derived + verified the owner.
1783/// Every well-formed tombstone at the rotation-stable dissolved coordinate, full records out
1784/// (the caller filters to the proven owner for the seal decision and runs
1785/// `migration::select_pointer` for the payload). Best-effort — a relay miss yields empty.
1786pub(crate) async fn dissolved_tombstone_records<T: Transport + ?Sized>(
1787    transport: &T,
1788    community: &Community,
1789) -> Vec<super::roster::DissolvedEdition> {
1790    let z = super::derive::dissolved_pseudonym(&community.id);
1791    let q = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
1792    transport
1793        .fetch(&q, &community.relays)
1794        .await
1795        .unwrap_or_default()
1796        .iter()
1797        .filter_map(|ev| super::roster::dissolved_tombstone_open(ev, &community.id))
1798        .collect()
1799}
1800
1801/// Publish the v1→v2 migration CARRIER: an owner-signed GroupDissolved tombstone whose
1802/// content carries the migration payload (§migration). One event seals v1 AND delivers the
1803/// v2 keys to every member. Sealed at BOTH coordinates like an ordinary dissolution
1804/// (rotation-stable + current-epoch fast path) with BYTE-IDENTICAL inner content, so the
1805/// member's fold and probe extract the same payload. NO link-retire/rekey — dissolution
1806/// moots every link. Does NOT seal locally: the wizard's own flip handles the owner's
1807/// transition to v2. Bunker-safe (signs through the active `VectorSigner`).
1808pub async fn publish_migration_carrier<T: Transport + ?Sized>(
1809    transport: &T,
1810    community: &Community,
1811    payload_content: &str,
1812) -> Result<(), String> {
1813    crate::db::scoped(async move {
1814        if !is_proven_owner(community) {
1815            return Err("only the community owner can migrate the community".to_string());
1816        }
1817        let signer = crate::signer::active_signer()?;
1818        let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the migration")?;
1819        let created_at = web_time::SystemTime::now()
1820            .duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1821        let unsigned = super::roster::build_group_dissolved_edition_unsigned_with_content(actor_pk, &community.id, created_at, payload_content);
1822        let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign migration carrier: {e}"))?;
1823        // Size gate on the ACTUAL sealed outer before publishing — the wizard aborts cleanly
1824        // rather than emit an event common relays would reject.
1825        let stable = super::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id)?;
1826        super::migration::check_outer_size(&stable)?;
1827        transport.publish_durable(&stable, &community.relays).await?;
1828        if let Ok(fast) = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch) {
1829            let _ = transport.publish_durable(&fast, &community.relays).await;
1830        }
1831        Ok(())
1832    })
1833    .await
1834}
1835
1836pub async fn dissolve_community<T: Transport + ?Sized>(
1837    transport: &T,
1838    community: &Community,
1839) -> Result<(), String> {
1840    crate::db::scoped(async move {
1841        let cid = community.id.to_hex();
1842
1843        // (a) Authority: owner-only, derived from the deed (never a cached claim). Stricter than re-founding.
1844        if !is_proven_owner(community) {
1845            return Err("only the community owner can dissolve (delete) the community".to_string());
1846        }
1847        let signer = crate::signer::active_signer()?;
1848        let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the dissolution")?;
1849
1850        // (b) Tombstone FIRST, must-succeed. The marker is the whole mechanism; build it chain-free (vsk=10,
1851        // fixed v1, no prev-hash) and seal under the CURRENT server root for the wire (re-anchoring keeps the
1852        // plane reachable there). A durable publish that fails returns Err so we never half-apply.
1853        let created_at = web_time::SystemTime::now()
1854            .duration_since(web_time::UNIX_EPOCH)
1855            .map(|d| d.as_secs())
1856            .unwrap_or(0);
1857        let unsigned = super::roster::build_group_dissolved_edition_unsigned(actor_pk, &community.id, created_at);
1858        let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign dissolution tombstone: {e}"))?;
1859        // Publish at the ROTATION-STABLE coordinate — the load-bearing path: a community-id-keyed
1860        // envelope at `dissolved_pseudonym`, found + openable by any client at any epoch, so a concurrent
1861        // re-founding can't strand the tombstone at an old epoch and let post-rotation joiners see a live group.
1862        let stable = super::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id)?;
1863        transport.publish_durable(&stable, &community.relays).await?;
1864        // Also publish at the current `control_pseudonym` (a current-epoch fast path so members fold it in their
1865        // normal control fetch without the extra probe). Best-effort — the stable publish above is the guarantee.
1866        if let Ok(outer) = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch) {
1867            let _ = transport.publish_durable(&outer, &community.relays).await;
1868        }
1869
1870        // (c) Best-effort retire the owner's OWN public invite-link editions WITHOUT the privatize re-founding
1871        // path: publish an empty per-creator link set (NO 3303 rekey, NO epoch bump — that rekey lives only in
1872        // `revoke_public_invite`) and tombstone+delete each owned token. A failure here is harmless (the
1873        // tombstone above already ends the community + an honest joiner refuses the stable-locator-dissolved
1874        // group). Skipped if we lack CREATE_INVITE (no links to retire).
1875        if caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1876            let _ = publish_my_invite_links(transport, community, &[]).await;
1877            if let Ok(records) = crate::db::community::list_public_invites(&cid) {
1878                for r in records {
1879                    let token = crate::simd::hex::hex_to_bytes_32(&r.token);
1880                    if let Ok(tombstone) = public_invite::build_public_invite_tombstone(&token) {
1881                        let _ = transport.publish_durable(&tombstone, &community.relays).await;
1882                    }
1883                    let _ = crate::db::community::delete_public_invite(&r.token);
1884                }
1885            }
1886        }
1887
1888        crate::db::community::set_community_dissolved(&cid)?;
1889        Ok(())
1890    })
1891    .await
1892}
1893
1894/// Publish the LOCAL user's OWN invite-link set as a `CREATE_INVITE`-gated vsk=8 control edition at
1895/// their per-creator coordinate — one of the per-creator lists members fold into the aggregate active-set.
1896/// `my_locators` is the FULL new set of THIS creator's active link locators (hex; the token in the URL is
1897/// the secret, never listed). Publish FIRST, then advance the head + merge into the cached aggregate on
1898/// success (relay-authoritative + phantom-head rule). A creator manages only their own list — no
1899/// `MANAGE_INVITES`. Carries the actor's `vac` citation so a non-owner creator's authority is verifiable.
1900pub async fn publish_my_invite_links<T: Transport + ?Sized>(
1901    transport: &T,
1902    community: &Community,
1903    my_locators: &[String],
1904) -> Result<(), String> {
1905    crate::db::scoped(async move {
1906        if !caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1907            return Err("you need the create-invite permission to publish invite links".to_string());
1908        }
1909        let cid = community.id.to_hex();
1910        let signer = crate::signer::active_signer()?;
1911        let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the invite links")?;
1912        let entity_id = super::derive::invite_links_locator(&community.id, &actor_pk.to_bytes());
1913        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
1914        let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
1915            Some((v, h)) => (v + 1, Some(h)),
1916            None => (1, None),
1917        };
1918        let created_at = web_time::SystemTime::now()
1919            .duration_since(web_time::UNIX_EPOCH)
1920            .map(|d| d.as_secs())
1921            .unwrap_or(0);
1922        // pinned authority: a non-owner creator cites the grant that authorizes them (owner cites nothing).
1923        let citation = authority_citation(community, &actor_pk.to_hex());
1924        let unsigned = super::roster::build_invite_links_edition_unsigned(actor_pk, &community.id, my_locators, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
1925        let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign invite-links edition: {e}"))?;
1926        let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1927        let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
1928        transport.publish_durable(&outer, &community.relays).await?;
1929        crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
1930        // Optimistically merge MY locators into the cached aggregate so `is_public` is right immediately;
1931        // the next `fetch_and_apply_invite_links` recomputes the authoritative union across all creators.
1932        let mut agg: std::collections::BTreeSet<String> =
1933            crate::db::community::get_community_invite_registry(&cid)?.into_iter().collect();
1934        agg.extend(my_locators.iter().cloned());
1935        crate::db::community::set_community_invite_registry(&cid, &agg.into_iter().collect::<Vec<_>>())?;
1936        crate::db::community::upsert_invite_link_set(&cid, &actor_pk.to_hex(), my_locators)?;
1937        Ok(())
1938    })
1939    .await
1940}
1941
1942/// Fetch the control plane and apply the folded invite-link AGGREGATE locally: UNION the locators of
1943/// every per-creator vsk=8 edition whose `creator` held `CREATE_INVITE` in the AUTHORIZED roster (the
1944/// keyless gate, same shape as the banlist's BAN check), advancing each authorized creator's head
1945/// (refuse-downgrade). The union is the source of truth for the Public/Private mode (`is_public`) + the
1946/// metrics — NOT join-gating (joining is envelope-only). Returns the aggregate set (empty = Private).
1947pub async fn fetch_and_apply_invite_links<T: Transport + ?Sized>(
1948    transport: &T,
1949    community: &Community,
1950) -> Result<Vec<String>, String> {
1951    fetch_and_apply_invite_links_inner(transport, community, None).await
1952}
1953
1954async fn fetch_and_apply_invite_links_inner<T: Transport + ?Sized>(
1955    transport: &T,
1956    community: &Community,
1957    prefolded: Option<super::roster::FoldedRoster>,
1958) -> Result<Vec<String>, String> {
1959    crate::db::scoped(async move {
1960        let cid = community.id.to_hex();
1961        let folded = match prefolded {
1962            Some(f) => f,
1963            None => fetch_control_folded(transport, community).await?,
1964        };
1965        let owner = proven_owner_hex(community);
1966        let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
1967        let mut aggregate: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1968        // Per-creator sets (attribution) for the "X has N active invite links" UI.
1969        let mut per_creator: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1970        for set in &folded.invite_link_sets {
1971            // authority: only a creator who held CREATE_INVITE counts. A self-minted list from an
1972            // unpermissioned member is dropped (the inner sig proves authorship, not authority).
1973            if !authorized.is_authorized(&set.creator.to_hex(), owner.as_deref(), super::roles::Permissions::CREATE_INVITE) {
1974                continue;
1975            }
1976            let held = crate::db::community::get_edition_head(&cid, &set.head.entity_hex)?.map(|(v, _)| v).unwrap_or(0);
1977            if set.head.version > held {
1978                crate::db::community::set_edition_head(&cid, &set.head.entity_hex, set.head.version, &set.head.self_hash)?;
1979            }
1980            aggregate.extend(set.locators.iter().cloned());
1981            per_creator.push(crate::db::community::InviteLinkSetRow {
1982                creator_hex: set.creator.to_hex(),
1983                locators: set.locators.clone(),
1984            });
1985        }
1986        // Retain-on-absence: a creator whose set we PERSISTED (proof a prior fold
1987        // verified their authorized edition) but whose edition THIS fold did not
1988        // return keeps their stored locators — absence is relay coverage, not
1989        // revocation (a real revocation is a NEWER edition, which folds above).
1990        // Without this, a partial control view writes an empty registry and
1991        // `is_public` misreads Private — which routes a public ban through the
1992        // read-cut path and severs link-joined members.
1993        //
1994        // Presence is judged BEFORE the authority gate: an edition that was fetched
1995        // but rejected as unauthorized is POSITIVE evidence the creator was demoted,
1996        // so their stored row drops now (keying on the authorized set instead would
1997        // retain a demoted creator forever — a permanent Public ratchet whose
1998        // skipped read-cuts leave banned members holding live keys). Only a truly
1999        // ABSENT edition retains; editions are durable at their locator, so the
2000        // next fold reaching a relay that holds one converges either way.
2001        {
2002            let present_creators: std::collections::HashSet<String> =
2003                folded.invite_link_sets.iter().map(|s| s.creator.to_hex()).collect();
2004            for row in crate::db::community::get_invite_link_sets(&cid)? {
2005                if present_creators.contains(&row.creator_hex) {
2006                    continue;
2007                }
2008                aggregate.extend(row.locators.iter().cloned());
2009                per_creator.push(row);
2010            }
2011        }
2012        let aggregate: Vec<String> = aggregate.into_iter().collect();
2013        crate::db::community::set_community_invite_registry(&cid, &aggregate)?;
2014        crate::db::community::replace_invite_link_sets(&cid, &per_creator)?;
2015        Ok(aggregate)
2016    })
2017    .await
2018}
2019
2020/// Fetch the Community's control plane and apply folded METADATA edits locally: the GroupRoot
2021/// (vsk=0 — community name/description/icon/banner) and each ChannelMetadata (vsk=2 — channel name). An
2022/// edition applies only if its signer held `MANAGE_METADATA` in the AUTHORIZED roster (the keyless 
2023/// gate, same as the producer) AND is strictly newer than the head we hold (refuse-downgrade by version).
2024/// Identity/transport fields (`server_root_key`, `relays`, `owner_attestation`) are NEVER taken from a
2025/// metadata edit — a manage-metadata admin edits DISPLAY, not the community's identity. Best-effort:
2026/// returns `Ok` even when nothing applied. This is what makes an owner/admin's edit sync to every member.
2027pub async fn fetch_and_apply_metadata<T: Transport + ?Sized>(
2028    transport: &T,
2029    community: &Community,
2030) -> Result<(), String> {
2031    fetch_and_apply_metadata_inner(transport, community, None).await
2032}
2033
2034async fn fetch_and_apply_metadata_inner<T: Transport + ?Sized>(
2035    transport: &T,
2036    community: &Community,
2037    prefolded: Option<super::roster::FoldedRoster>,
2038) -> Result<(), String> {
2039    crate::db::scoped(async move {
2040        let cid = community.id.to_hex();
2041        let folded = match prefolded {
2042            Some(f) => f,
2043            None => fetch_control_folded(transport, community).await?,
2044        };
2045        let owner = proven_owner_hex(community);
2046        let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
2047        // Community display = MANAGE_METADATA; channel display = MANAGE_CHANNELS (— channel edits are a
2048        // channel-management action, matching `build_channel_metadata_edition`'s contract).
2049        let manage = super::roles::Permissions::MANAGE_METADATA;
2050        let manage_channels = super::roles::Permissions::MANAGE_CHANNELS;
2051
2052        // Apply onto the freshest local state (the caller's struct may predate other syncs). `save_community`
2053        // UPSERTs the community row, so `created_at` (the kick join-anchor) and the banlist are preserved.
2054        let mut current = match crate::db::community::load_community(&community.id)? {
2055            Some(c) => c,
2056            None => return Ok(()),
2057        };
2058        let mut dirty = false;
2059        // (entity_hex, version, self_hash, inner_id, is_converge) of each edition applied — written AFTER a
2060        // successful save. `is_converge` routes a same-version fork-resolution to converge_edition_head; a
2061        // strictly-higher version is a plain advance.
2062        type HeadUpdate = (String, u64, [u8; 32], [u8; 32], bool);
2063        let mut head_updates: Vec<HeadUpdate> = Vec::new();
2064
2065        // Decide whether a folded display head should apply, and how. A strictly-higher version ADVANCES the
2066        // refuse-downgrade floor. An equal version with a DIFFERENT, lower-inner-id edition CONVERGES a
2067        // concurrent fork: two authorized editors editing from the same base both produce v+1, and every
2068        // client must adopt the same deterministic winner (lowest inner edition id). Mirrors
2069        // converge_edition_head's SQL (a NULL/None held id is "always replaceable") so we never apply a
2070        // display edit the head write would then refuse. `Some(is_converge)` → apply; `None` → keep the floor.
2071        let decide = |entity_hex: &str, head: &super::roster::EntityHead| -> Result<Option<bool>, String> {
2072            let held = crate::db::community::get_edition_head(&cid, entity_hex)?;
2073            let held_v = held.map(|(v, _)| v).unwrap_or(0);
2074            if head.version > held_v {
2075                return Ok(Some(false)); // advance
2076            }
2077            if head.version == held_v && held.map(|(_, h)| h) != Some(head.self_hash) {
2078                let held_id = crate::db::community::get_edition_head_inner_id(&cid, entity_hex)?;
2079                if held_id.is_none() || Some(head.inner_id) < held_id {
2080                    return Ok(Some(true)); // converge to the lower-inner-id authorized winner
2081                }
2082            }
2083            Ok(None)
2084        };
2085
2086        // Author-aware descending scan: the candidates are sorted (version desc, inner-id asc), so
2087        // the first whose author CURRENTLY holds MANAGE_METADATA is both the highest-version AND (within a
2088        // version) the deterministic tiebreak winner. Skips a demoted author's editions, incl. a same-version
2089        // forgery. No authorized candidate → keep the floor.
2090        if let Some(c) = folded.root_candidates.iter()
2091            .find(|c| authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), manage))
2092        {
2093            let head = &c.head;
2094            if let Some(is_converge) = decide(&head.entity_hex, head)? {
2095                let meta = &c.meta;
2096                // Apply only the editable display fields.
2097                // `meta.owner_attestation` is DELIBERATELY NOT applied: the owner is the deed, anchored from
2098                // the invite/founding. Letting an editable field redefine it = a one-edit takeover, so
2099                // ownership is NON-TRANSFERABLE for the MVP. (Transfer — and eventually owner quorums — will
2100                // be a deliberate owner-signed action, never a metadata side-effect.)
2101                // `meta.relays` is also dropped for now (silently following an embedded relay list is a
2102                // herding/partition vector). Relay migration is likewise deferred to a first-class,
2103                // permissioned, ADDITIVE (union-not-replace) action.
2104                current.name = meta.name.clone();
2105                current.description = meta.description.clone();
2106                current.icon = meta.icon.clone();
2107                current.banner = meta.banner.clone();
2108                dirty = true;
2109                head_updates.push((head.entity_hex.clone(), head.version, head.self_hash, head.inner_id, is_converge));
2110            }
2111        }
2112        // Channels mirror GroupRoot: per channel, an author-aware descending scan over its candidates (sorted
2113        // version desc, inner-id asc) → the highest whose author CURRENTLY holds MANAGE_CHANNELS, then decide()
2114        // advance/converge. A concurrent same-version rename converges to the same deterministic winner on every
2115        // client; a demoted author's edition (incl. a same-version forgery) is skipped. Candidates arrive grouped
2116        // + sorted per channel, so the first authorized per channel is the winner.
2117        let mut resolved_channels: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2118        for cm in &folded.channel_candidates {
2119            if resolved_channels.contains(&cm.channel_id) {
2120                continue; // this channel already resolved (its candidates are contiguous + sorted)
2121            }
2122            if !authorized.is_authorized(&cm.author.to_hex(), owner.as_deref(), manage_channels) {
2123                continue; // skip a demoted author; keep scanning lower candidates for this channel
2124            }
2125            resolved_channels.insert(cm.channel_id);
2126            let Some(is_converge) = decide(&cm.head.entity_hex, &cm.head)? else { continue };
2127            if let Some(ch) = current.channels.iter_mut().find(|c| c.id.0 == cm.channel_id) {
2128                ch.name = cm.meta.name.clone();
2129                dirty = true;
2130                head_updates.push((cm.head.entity_hex.clone(), cm.head.version, cm.head.self_hash, cm.head.inner_id, is_converge));
2131            }
2132        }
2133
2134        if dirty {
2135            crate::db::community::save_community(&current)?;
2136            // Persist heads in the SAME save block so a subsequent re-assert/edit chains prev_hash from the
2137            // converged head, not a stale one (else the fork regenerates at the next version).
2138            for (entity_hex, version, self_hash, inner_id, is_converge) in &head_updates {
2139                if *is_converge {
2140                    crate::db::community::converge_edition_head(&cid, entity_hex, *version, self_hash, inner_id)?;
2141                } else {
2142                    crate::db::community::set_edition_head_with_id(&cid, entity_hex, *version, self_hash, inner_id)?;
2143                }
2144            }
2145        }
2146        Ok(())
2147    })
2148    .await
2149}
2150
2151/// The computed Public/Private mode: a community is PUBLIC iff the folded per-creator invite-link
2152/// aggregate has ≥1 active locator, else PRIVATE. Every member computes the same value from the folded
2153/// editions, which is what lets it drive rekey-on-removal consistently (Private removals rekey the base
2154/// to the roster; Public ones don't — anti-memberlist). Reads the cached aggregate, which is only as
2155/// fresh as the last successful latest-page sync ([`fetch_and_apply_invite_links`], wired best-effort
2156/// into the sync path) — a member who only scrolled back, or whose sync failed, can hold a stale mode
2157/// (which is why `revoke_public_invite` refreshes the aggregate before deciding to privatize).
2158pub fn is_public(community: &Community) -> Result<bool, String> {
2159    Ok(!crate::db::community::get_community_invite_registry(&community.id.to_hex())?.is_empty())
2160}
2161
2162/// Recompute the LOCAL user's OWN invite-link set from their currently-retained public-invite tokens and
2163/// publish it (per-creator), so every member's computed Public/Private mode tracks reality. Returns
2164/// this creator's new active link-locator set (empty = they hold no links). Expired links are dropped —
2165/// they can't be joined, so they don't keep a community Public.
2166async fn republish_my_invite_links<T: Transport + ?Sized>(
2167    transport: &T,
2168    community: &Community,
2169) -> Result<Vec<String>, String> {
2170    let cid = community.id.to_hex();
2171    let now = web_time::SystemTime::now()
2172        .duration_since(web_time::UNIX_EPOCH)
2173        .map(|d| d.as_secs())
2174        .unwrap_or(0);
2175    let locators: Vec<String> = crate::db::community::list_public_invites(&cid)?
2176        .iter()
2177        .filter(|r| r.expires_at.is_none_or(|e| (e as u64) > now))
2178        .map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
2179        .collect();
2180    publish_my_invite_links(transport, community, &locators).await?;
2181    Ok(locators)
2182}
2183
2184/// Fetch + INGEST the channel append-plane across ALL held epochs (messages + presence) into the local
2185/// store. The retain set for a rekey is computed from this store (`community_member_activity`), and the
2186/// no-role chatters live ONLY here — not in the control plane — so a privatize/ban must observe it first or
2187/// it would shed anyone the re-founder hasn't already synced. Best-effort per channel; uses the multi-epoch
2188/// fetch so activity under any retained epoch counts. `std::sync::Arc<crate::db::Session>`-gated across the fetches.
2189async fn observe_channel_activity<T: Transport + ?Sized>(
2190    transport: &T,
2191    community: &Community,
2192) -> Result<(), String> {
2193    crate::db::scoped(async move {
2194        let session = crate::db::current_session();
2195        let my_pk = crate::state::my_public_key().ok_or("no local identity to observe channel activity")?;
2196        for channel in &community.channels {
2197            let events = super::send::fetch_channel_events(transport, community, channel)
2198                .await
2199                .unwrap_or_default();
2200            let outcomes = {
2201                let mut st = crate::state::STATE.lock().await;
2202                super::inbound::process_channel_batch(&mut st, &events, channel, &my_pk)
2203            };
2204            let ch_hex = channel.id.to_hex();
2205            // No delete outcomes on this read-only observation sweep, so the whole channel's
2206            // message saves land in one batched transaction at the end.
2207            let mut pending: Vec<&crate::types::Message> = Vec::new();
2208            for o in &outcomes {
2209                match o {
2210                    super::inbound::IncomingEvent::NewMessage(m)
2211                    | super::inbound::IncomingEvent::Updated { message: m, .. } => {
2212                        pending.push(m);
2213                    }
2214                    super::inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label } => {
2215                        let et = if *joined {
2216                            crate::stored_event::SystemEventType::MemberJoined
2217                        } else {
2218                            crate::stored_event::SystemEventType::MemberLeft
2219                        };
2220                        let note = invited_by.as_ref().map(|by| match invited_label {
2221                            Some(l) if !l.is_empty() => format!("{by}|{l}"),
2222                            _ => by.clone(),
2223                        });
2224                        let _ = crate::db::events::save_system_event_at(event_id, &ch_hex, et, npub, note.as_deref(), *created_at, invited_by.as_deref(), invited_label.as_deref()).await;
2225                    }
2226                    super::inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at } => {
2227                        persist_webxdc_signal(&ch_hex, npub, topic_id, node_addr.as_deref(), event_id, *created_at).await;
2228                    }
2229                    _ => {}
2230                }
2231            }
2232            crate::db::events::flush_message_batch(&ch_hex, &mut pending, &session).await;
2233        }
2234        Ok(())
2235    })
2236    .await
2237}
2238
2239/// FRESHEN-BEFORE-WRITE guard for an administrative write (rekey / ban / kick / grant / revoke / metadata):
2240/// hop any base rotation + fold the LATEST control plane from ALL relays + (for a rekey) ingest channel
2241/// activity, so the write acts on the freshest reachable truth — not just a stale local view. The
2242/// demonstrated bug this fixes: privatizing before observing a member's activity wrongly cut them.
2243///
2244/// BEST-EFFORT, not hard-fail: the refuse-downgrade FLOORS already prevent the write from acting on
2245/// rolled-back state (the fold can't apply below what we hold), so blocking when relays are unreachable
2246/// would only forbid legitimate admin actions during an outage (e.g. you couldn't ban anyone). The one
2247/// hard stop is REMOVAL — if an authorized base rotation has cut us, we must not be writing at all.
2248/// Returns the refreshed community.
2249pub async fn sync_before_admin_write<T: Transport + ?Sized>(
2250    transport: &T,
2251    community: &Community,
2252    observe_activity: bool,
2253) -> Result<Community, String> {
2254    // Hop any base rotation we missed; abort only if it REMOVED us (we shouldn't be writing then).
2255    if catch_up_server_root(transport, community).await?.removed {
2256        return Err("you have been removed from this community".to_string());
2257    }
2258    let community = crate::db::community::load_community(&community.id)?
2259        .ok_or("community gone during admin sync")?;
2260    let cid = community.id.to_hex();
2261    // ONE fresh control fetch+fold from all relays, applied (banlist/roles/metadata/invites) so the roster +
2262    // floors the write reads are as current as the relays can make them; its raw event count doubles as the
2263    // isolation signal (no separate probe). Floors guard against stale/rolled-back data, so we DON'T block on
2264    // "can't confirm latest" — only on true ISOLATION: if we KNOW a control plane exists (we hold edition
2265    // heads) but NO relay returned ANY control event, an admin decision made blind (and unpublishable) must
2266    // not happen. A community with no published plane (no local heads) has nothing to confirm → proceed.
2267    // Full evidence: this fold's `is_public` read decides ban-vs-read-cut — a
2268    // partial view misreading "Private" would sever link-joined members.
2269    let responded = fetch_and_apply_control_full(transport, &community).await.map(|n| n > 0).unwrap_or(false);
2270    let hold_local_heads = !crate::db::community::get_all_edition_heads_epoched(&cid)?.is_empty();
2271    if hold_local_heads && !responded {
2272        return Err("can't reach any relay to confirm this community's current state — administrative actions are blocked while offline (try again when connected)".to_string());
2273    }
2274    let community = crate::db::community::load_community(&community.id)?
2275        .ok_or("community gone during admin sync")?;
2276    // For a rekey, ingest channel activity so the retain set sees no-role chatters too (they live only in
2277    // the message/presence history, not the control plane).
2278    if observe_activity {
2279        let _ = observe_channel_activity(transport, &community).await;
2280    }
2281    crate::db::community::load_community(&community.id)?.ok_or("community gone during admin sync".to_string())
2282}
2283
2284/// Drive a read-cut (re-founding) to completion, DURABLY. Sets `read_cut_pending` as the intent BEFORE
2285/// the work and clears it only on full success — so a transient failure (relay outage, power cut, mid-cut
2286/// account swap) leaves it pending, and the next ban OR a community sync ([`retry_pending_read_cut`])
2287/// resumes EXACTLY where it stopped (no double base rotation, channels picked up where they left off).
2288///
2289/// `fresh` distinguishes a NEW exclusion delta (a ban add / a privatize transition) from a pure RESUME: a
2290/// fresh delta bumps `read_cut_target_epoch` to `base + 1` so the base MUST rotate past it (excluding the
2291/// newly-removed member) and every channel is re-cut; a resume keeps the in-flight target so an interrupted
2292/// cut finishes without forcing an extra base rotation.
2293async fn run_read_cut<T: Transport + ?Sized>(
2294    transport: &T,
2295    community: &Community,
2296    fresh: bool,
2297) -> Result<(), String> {
2298    crate::db::scoped(async move {
2299        let cid = community.id.to_hex();
2300        if fresh {
2301            // Compute the target from the FRESHEST base epoch in the DB (the passed struct may predate a recent
2302            // rotation), so a fresh exclusion always lands at an epoch strictly past the current root.
2303            let base = crate::db::community::load_community(&community.id)?
2304                .map(|c| c.server_root_epoch.0)
2305                .unwrap_or(community.server_root_epoch.0);
2306            crate::db::community::set_read_cut_target_epoch(&cid, base.saturating_add(1))?;
2307        }
2308        crate::db::community::set_read_cut_pending(&cid, true)?;
2309        reseal_base_to_observed(transport, community).await?;
2310        crate::db::community::set_read_cut_pending(&cid, false)?;
2311        Ok(())
2312    })
2313    .await
2314}
2315
2316/// Re-seal the base / server-root key to the current OBSERVED-PARTICIPANTS set
2317/// (`community_member_activity` — everyone who posted, reacted, or announced a join, minus those who
2318/// left or were banned). The shared read-cut behind two actions: PRIVATIZE (revoking the last link →
2319/// re-found, sealing link-joined lurkers) and REKEY-ON-REMOVAL (a ban in a Private community →
2320/// forward-exclude the banned member, who is absent from the observed set because the banlist filters
2321/// them out). The re-keyer (here, the owner) is always included (`rotate_server_root` adds its own self).
2322/// Honest joiners are observable because they emit a `join` Presence on accept, so a removed member
2323/// is the only one shed. `rotate_server_root` re-anchors the control plane (incl. the current banlist +
2324/// the registry head) under the new epoch, so post-rotation peers read complete authority state.
2325async fn reseal_base_to_observed<T: Transport + ?Sized>(
2326    transport: &T,
2327    community: &Community,
2328) -> Result<(), String> {
2329    crate::db::scoped(async move {
2330        let cid = community.id.to_hex();
2331        // BLOCK-UNTIL-SYNCED: fold the latest control plane + ingest channel activity from ALL relays BEFORE
2332        // computing the retain set, so it reflects current truth (roster ∪ presence ∪ activity), not a stale
2333        // local view. The demonstrated bug: privatizing before observing a member's posts cut them. Fails closed
2334        // if no relay confirms our head — better to abort the rekey than shed real members on a partial view.
2335        let community = &sync_before_admin_write(transport, community, true).await?;
2336        // `community_member_activity` returns npubs in the events table's BECH32 form (`npub1...`), so parse
2337        // with `PublicKey::parse` (bech32 OR hex) — `from_hex` would reject every one, emptying the set and
2338        // sealing the community down to the owner alone (the re-founding inverted).
2339        let participants: Vec<nostr_sdk::prelude::PublicKey> = crate::db::community::community_member_activity(&cid)?
2340            .into_iter()
2341            .filter_map(|(npub, _)| nostr_sdk::prelude::PublicKey::parse(&npub).ok())
2342            .collect();
2343        // RESUMABLE re-founding (durable across interruption — outage, power cut, mass relay failure mid-cut).
2344        // A re-founding rotates the base THEN each channel key; a naive retry would re-run BOTH from scratch
2345        // (a second base epoch + full control-plane re-anchor, and re-rotation of channels already done).
2346        //
2347        // `target` = the base epoch THIS pending cut must reach (set durably when the cut was triggered). The
2348        // base is rotated ONLY while the OBSERVABLE base epoch is below it — so a crash AFTER the base advanced
2349        // but BEFORE any flag write never double-rotates (the decision reads the real epoch, not a separate
2350        // flag that could be out of step). `rotate_server_root` reuses its archived root + recomputes the epoch
2351        // from the DB head, so even a retry of the base itself is idempotent (no same-epoch fork).
2352        let target = crate::db::community::get_read_cut_target_epoch(&cid)?;
2353        if community.server_root_epoch.0 < target {
2354            rotate_server_root(transport, community, &participants).await?;
2355        }
2356        // O2: the base rotation cuts the control plane + @everyone, but channel MESSAGES are sealed under
2357        // per-channel keys — so a removed member who held a channel key would keep reading NEW messages.
2358        // Rotate every channel key to the retained set. Reload first so we see the freshest per-channel rekey
2359        // progress + the new base epoch. (std::sync::Arc<crate::db::Session>: a mid-rotation account swap must not reload/rotate
2360        // against the wrong account's pool.)
2361        let community = crate::db::community::load_community(&community.id)?
2362            .ok_or("community gone after base rotation")?;
2363        let cut_epoch = community.server_root_epoch.0;
2364        // / A-B2 fix: envelope + address each channel rekey under the PRIOR (pre-rotation) root, NOT the new
2365        // one — mirroring the base rekey. Concurrent re-founders each mint their OWN new root; base convergence
2366        // adopts ONE and the losers DROP theirs, so a channel rekey sealed under the new root becomes unreadable
2367        // to any loser (the live-proven channel fork). The prior root is the shared key EVERY retained member
2368        // still holds through the convergence, so all can open + apply the channel rekey and converge.
2369        let prior_root = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, cut_epoch.saturating_sub(1))?
2370            .unwrap_or(*community.server_root_key.as_bytes()); // epoch 0 (no prior) → current root (no fork risk)
2371        for channel in &community.channels {
2372            let ch_hex = channel.id.to_hex();
2373            // Skip channels already rotated for this read-cut — a retry resumes exactly where it stopped, so
2374            // each pass makes monotonic forward progress (no re-publishing rekeys for finished channels).
2375            if crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex)? >= cut_epoch {
2376                continue;
2377            }
2378            rotate_channel(transport, &community, &channel.id, &participants, &prior_root).await?;
2379            crate::db::community::mark_channel_rekeyed_at_server_epoch(&cid, &ch_hex, cut_epoch)?;
2380        }
2381        Ok(())
2382    })
2383    .await
2384}
2385
2386/// The result of applying a received channel Rekey (3303).
2387#[derive(Debug, PartialEq, Eq)]
2388pub enum RekeyOutcome {
2389    /// The new key was recovered + committed. `head_advanced` is true if it became the channel's
2390    /// current epoch (a catch-up of an OLDER epoch archives the key but leaves the head, so `false`).
2391    Applied { head_advanced: bool },
2392    /// No blob at my recipient locator — I'm not in this rotation's recipient set (a non-member of the
2393    /// channel, or the member this removal deliberately excluded). Expected, NOT an error.
2394    NotARecipient,
2395}
2396
2397/// Apply a received, already-opened channel Rekey ([`super::rekey::open_rekey_event`]) for `community`.
2398///
2399/// Verifies the rotator's authority (`MANAGE_CHANNELS`) against the current roster (owner supreme,
2400///), checks chain continuity against the held prior-epoch key (fork detection — when held),
2401/// finds + opens MY per-recipient blob, and commits the new key via `advance_channel_epoch` (the
2402/// atomic archive+head write). `std::sync::Arc<crate::db::Session>`-gated: the caller's fetch can straddle an account swap,
2403/// so the DB write is re-validated immediately before it. Does NOT fetch — the catch-up fetch loop is
2404/// a later layer. (Scope-pinned + version-pinned authority — evaluating the rotator's rank at the
2405/// roster version the rekey cites, under block-until-synced — is deferred; server-root rotation has
2406/// its own apply, deferred.)
2407pub fn apply_channel_rekey(
2408    community: &Community,
2409    parsed: &super::rekey::ParsedRekey,
2410) -> Result<RekeyOutcome, String> {
2411    // Fully synchronous (no `.await`), so a session swap can't preempt between the MY_SECRET_KEY read
2412    // and the DB write — one captured guard + one re-check before the write suffices. If a remote
2413    // signer (bunker) open path ever adds an await here, MY_SECRET_KEY must be re-read after it.
2414    let session = crate::db::current_session();
2415
2416    // Scope must be a channel of THIS community (server-root rotation is a separate, deferred path).
2417    let channel_id = match parsed.scope {
2418        super::derive::RekeyScope::Channel(c) => c,
2419        super::derive::RekeyScope::ServerRoot => {
2420            return Err("server-root rotation uses apply_server_root_rekey, not the channel path".to_string())
2421        }
2422    };
2423    if !community.channels.iter().any(|c| c.id == channel_id) {
2424        return Err("rekey targets a channel not in this community".to_string());
2425    }
2426    let cid = community.id.to_hex();
2427    let channel_hex = channel_id.to_hex();
2428
2429    // Authority: the rotator must hold MANAGE_CHANNELS per the current roster; the owner is
2430    // supreme. Reject an unauthorized rotation rather than fail open.
2431    // TODO(scope): is_authorized unions MANAGE_CHANNELS across ALL the rotator's roles regardless of
2432    // RoleScope — once channel-scoped roles become grantable, gate on the scope covering THIS channel,
2433    // else a Channel(other)-scoped grant would wrongly authorize rotating this one. MVP roles are all
2434    // Server-scoped, so the hole is currently unreachable.
2435    let owner = proven_owner_hex(community);
2436    let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
2437        // A DB hiccup degrades (fail-closed) to owner-only authorization; surface it so the resulting
2438        // "lacks MANAGE_CHANNELS" rejection isn't mistaken for a real authority problem.
2439        crate::log_warn!("rekey apply: roster read failed ({e}); authorizing owner only");
2440        Default::default()
2441    });
2442    if !roster.is_authorized(
2443        &parsed.rotator.to_hex(),
2444        owner.as_deref(),
2445        super::roles::Permissions::MANAGE_CHANNELS,
2446    ) {
2447        return Err("rekey rotator lacks MANAGE_CHANNELS authority".to_string());
2448    }
2449
2450    // Chain continuity — relaxed for FORK-CONVERGENCE: if I hold the prior-epoch key this rekey cites
2451    // and its commitment matches, great (the normal contiguous case). If it MISMATCHES, I'm on a LOSING
2452    // FORK of the prior epoch (e.g. a concurrent re-founding I lost) while this rekey extends the WINNING
2453    // fork. It is NOT a foreign chain: the rotator is already authority-verified above (holds MANAGE_CHANNELS)
2454    // and the ECDH blob below proves it's addressed to ME. So ADOPT it — converge forward onto the authorized
2455    // chain — rather than reject and strand myself on the dead fork forever. (Authority + recipient are the
2456    // real gates; the commitment is continuity, which must yield to convergence. Replays of OLD epochs can't
2457    // reach here: the forward walk only fetches epochs past my head.) My divergent prior-epoch key stays as
2458    // local history; going forward I'm on the converged key.
2459    if let Some(prev_key) = crate::db::community::held_epoch_key(&cid, &channel_hex, parsed.prev_epoch.0)? {
2460        if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_key) != parsed.prev_key_commitment {
2461            crate::log_warn!(
2462                "channel rekey to epoch {} cites a prior-epoch key I don't hold (I'm on a losing fork of epoch {}) — converging forward onto the authorized chain",
2463                parsed.new_epoch.0, parsed.prev_epoch.0
2464            );
2465        }
2466    }
2467
2468    // Find + open MY blob (compute my own recipient locator, no trial-decryption).
2469    let my_keys = crate::state::MY_SECRET_KEY
2470        .to_keys()
2471        .ok_or("no local identity to open the rekey blob")?;
2472    let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
2473    let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
2474    let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
2475        Some(b) => b,
2476        None => return Ok(RekeyOutcome::NotARecipient),
2477    };
2478    let new_key =
2479        super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
2480
2481    // Commit (N2 dual-write). Re-validate the session straddling the caller's fetch before writing.
2482    if !session.is_live() {
2483        return Err("session changed during rekey apply".to_string());
2484    }
2485    let head_advanced =
2486        crate::db::community::advance_channel_epoch(&cid, &channel_hex, parsed.new_epoch.0, &new_key)?;
2487    Ok(RekeyOutcome::Applied { head_advanced })
2488}
2489
2490/// Mint the new key for a rotation, OR reuse the one a prior (failed-mid-publish) attempt already minted
2491/// and archived for this `(scope, epoch)`. Reuse is the FORK-SAFETY crux of splitting: a rotation's key
2492/// is minted ONCE and persisted to the epoch-key archive BEFORE publishing, so a retry re-publishes the
2493/// SAME key across all chunks — never a second random root for the same epoch (which would split
2494/// recipients onto incompatible keys). Returns the (zeroized) key.
2495fn mint_or_reuse_rotation_key(cid: &str, scope_id: &str, epoch: u64) -> Result<zeroize::Zeroizing<[u8; 32]>, String> {
2496    if let Some(k) = crate::db::community::held_epoch_key(cid, scope_id, epoch)? {
2497        return Ok(zeroize::Zeroizing::new(k));
2498    }
2499    let k = zeroize::Zeroizing::new(super::random_32());
2500    crate::db::community::store_epoch_key(cid, scope_id, epoch, &k)?;
2501    Ok(k)
2502}
2503
2504/// Publish a rotation's per-recipient blobs as one OR MORE 3303 events, SPLIT into chunks of
2505/// `MAX_REKEY_BLOBS` so each stays under the relay size limit (e.g. 200 recipients → a 120-blob event +
2506/// an 80-blob event). All chunks share the SAME address (the builder derives it from scope/epoch, not
2507/// the blobs) and carry the SAME new key, so a recipient finds + recovers their key from whichever chunk
2508/// holds their blob. Each chunk is published durably; FAIL-FAST if a chunk reaches no relay (the caller
2509/// leaves its head unadvanced; because the key is persisted + reused on retry, re-publishing carries the
2510/// SAME key → no same-epoch fork).
2511async fn publish_rekey_chunked<T, F>(
2512    transport: &T,
2513    relays: &[String],
2514    blobs: &[super::rekey::RekeyBlob],
2515    build: F,
2516) -> Result<(), String>
2517where
2518    T: Transport + ?Sized,
2519    F: Fn(&[super::rekey::RekeyBlob]) -> Result<Event, String>,
2520{
2521    if blobs.is_empty() {
2522        return Err("rekey has no recipients".to_string());
2523    }
2524    for chunk in blobs.chunks(super::rekey::MAX_REKEY_BLOBS) {
2525        let event = build(chunk)?;
2526        transport.publish_durable(&event, relays).await?;
2527    }
2528    Ok(())
2529}
2530
2531/// Rotate a channel's key (a channel rekey): mint a fresh-random key for `current_epoch + 1`,
2532/// deliver it to `recipients` as one self-proving 3303 event (epoch + every recipient blob + the
2533/// prior-epoch commitment + my real-npub authority sig, all in one — the design tenet), publish it,
2534/// then advance MY local epoch. Returns the new epoch.
2535///
2536/// The caller supplies the recipient set (the recipient-set policy — "everyone who stays" — is a
2537/// separate layer); I am always added (so my other devices recover the key). I must hold
2538/// `MANAGE_CHANNELS`. **Publish FIRST, advance my head only after a successful publish** — moving my
2539/// head to an epoch no peer received would strand me. (A post-publish session swap leaves peers ahead
2540/// of my local head, which self-heals: the rekey is server-root-addressed, so I re-derive my own key
2541/// on the next fetch.) `std::sync::Arc<crate::db::Session>`-gated across the publish await.
2542pub async fn rotate_channel<T: Transport + ?Sized>(
2543    transport: &T,
2544    community: &Community,
2545    channel_id: &super::ChannelId,
2546    recipients: &[nostr_sdk::prelude::PublicKey],
2547    // the server root this rekey is ENVELOPED + ADDRESSED under. A standalone channel removal passes the
2548    // CURRENT root. A re-founding (base rotation) passes the PRIOR (pre-rotation) root — exactly like the
2549    // base rekey (`base_rekey_pseudonym(prior_root, …)`) — so every RETAINED member can still open it after
2550    // the base converges to ONE winning root (the losers dropped their own new root). Sealing under the new
2551    // root instead would strand any base-fork loser on an unreadable channel rekey.
2552    envelope_root: &[u8; 32],
2553) -> Result<u64, String> {
2554    crate::db::scoped(async move {
2555        let cid = community.id.to_hex();
2556
2557        // Authority: I must hold MANAGE_CHANNELS (owner supreme). A rekey needs the RAW local key
2558        // (the blob locator is a ConversationKey ECDH, which NIP-46 can't expose) — so a bunker account can
2559        // administer via editions but not rekey. Fails clearly here rather than silently.
2560        let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("a key rotation requires a local key (bunker/NIP-46 accounts can't rekey)")?;
2561        let owner = proven_owner_hex(community);
2562        let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
2563        if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
2564            return Err("not authorized to rotate this channel (no MANAGE_CHANNELS)".to_string());
2565        }
2566
2567        // Current epoch + key (the chain link we extend). `channel.key` is the head key, kept in lockstep
2568        // with the archived prev_epoch key by `advance_channel_epoch`, so the commitment computed here
2569        // matches what the apply side verifies against `held_epoch_key(prev_epoch)`.
2570        let channel = community
2571            .channels
2572            .iter()
2573            .find(|c| &c.id == channel_id)
2574            .ok_or("channel not found in community")?;
2575        let prev_epoch = channel.epoch;
2576        let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2577        let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, channel.key.as_bytes());
2578        // The fresh channel key — minted ONCE + archived, reused on a retry (fork-safety, see
2579        // `mint_or_reuse_rotation_key`). Zeroized on drop.
2580        let new_key = mint_or_reuse_rotation_key(&cid, &channel_id.to_hex(), new_epoch.0)?;
2581
2582        // Recipient set = the supplied stayers ∪ me (deduped), each wrapped a per-recipient blob. Published
2583        // SPLIT across ≤MAX_REKEY_BLOBS-blob events so a large channel rotates in multiple 64KB-safe
2584        // events at one address; a recipient recovers from whichever chunk holds their blob.
2585        let mut seen = std::collections::HashSet::new();
2586        let mut blobs = Vec::new();
2587        for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
2588            if !seen.insert(pk.to_hex()) {
2589                continue;
2590            }
2591            blobs.push(super::rekey::build_rekey_blob(
2592                my_keys.secret_key(), pk, super::derive::RekeyScope::Channel(*channel_id), new_epoch, &new_key,
2593            )?);
2594        }
2595
2596        // Publish FIRST (all chunks) — only advance my own head once peers can actually receive the new key.
2597        publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
2598            super::rekey::build_channel_rekey_event(
2599                &Keys::generate(), &my_keys, envelope_root, channel_id,
2600                new_epoch, prev_epoch, &prev_commit, chunk,
2601            )
2602        })
2603        .await?;
2604        crate::db::community::advance_channel_epoch(&cid, &channel_id.to_hex(), new_epoch.0, &new_key)?;
2605        Ok(new_epoch.0)
2606    })
2607    .await
2608}
2609
2610/// Emit a privatize/rekey progress step to the UI (no-op on headless clients via the unregistered emitter).
2611/// `pct` is OVERALL progress 0-100 across the whole rotation; `label` is layman-facing. The frontend renders
2612/// a determinate ring + this label in an unclosable modal so the user is guided through the multi-second op.
2613fn emit_rekey_progress(label: &str, pct: u8) {
2614    crate::emit_event("community_rekey_progress", &serde_json::json!({ "label": label, "pct": pct }));
2615}
2616
2617/// Rotate the SERVER ROOT (a base rotation — the Private-removal / re-founding read-cut), the
2618/// complete orchestration: mint a fresh-random new root for `current_base_epoch + 1`, deliver it to
2619/// `recipients` as one self-proving server-root rekey (enveloped under the PRIOR root, addressed by
2620/// `base_rekey_pseudonym`), **re-anchor the control plane under the new epoch**, and only then
2621/// advance MY base head. Returns the new base epoch.
2622///
2623/// I am always added to the recipient set (multi-device). I must hold `BAN` (server-wide rotation
2624/// authority; owner supreme). The ordering is the safety contract: publish the base rekey → re-anchor →
2625/// advance head, with the **head-advance gated on a successful, count-complete re-anchor** — so a
2626/// post-rotation joiner who holds only the new root always reaches current authority, and a withholding
2627/// relay can't advance us over a thinned control plane. The recipient-set policy ("who stays") is still
2628/// the caller's (privatize/removal flow, #7/#8). `pub(crate)` — exposed only inside the crate until that
2629/// flow wraps it. Re-anchor carries the whole 3308 control plane (roles, grants, banlist, GroupRoot,
2630/// channel metadata) — every authority + display entity is preserved across a base rotation.
2631// Called by the privatize re-founding flow (`privatize_reseal`); also exercised directly by tests.
2632pub(crate) async fn rotate_server_root<T: Transport + ?Sized>(
2633    transport: &T,
2634    community: &Community,
2635    recipients: &[nostr_sdk::prelude::PublicKey],
2636) -> Result<u64, String> {
2637    crate::db::scoped(async move {
2638        let cid = community.id.to_hex();
2639
2640        // a re-founding cannot cross a tombstone. A dissolved community never rotates the base again.
2641        if crate::db::community::get_community_dissolved(&cid)? {
2642            return Err("community is dissolved; it cannot be re-founded".to_string());
2643        }
2644
2645        // Authority: I must hold BAN (server-wide rotation; owner supreme). Re-founding re-WRAPS each entity
2646        // head verbatim (never re-authors), so an HONEST re-founder of any rank preserves everything: grants keep
2647        // their original granter and the owner deed rides along untouched (ownership is unstealable — the deed is
2648        // owner-signed and verified from the invite bundle, never from the snapshot).
2649        // KNOWN MVP LIMITATION (audited, accepted): a MALICIOUS non-owner admin (modified client) can OMIT a peer
2650        // admin's grant from the snapshot to demote them — a privilege escalation, since epoch-primary floors drop
2651        // the prior-epoch floors so followers can't detect the omission. Accepted because admins are owner-
2652        // appointed/trusted and the owner recovers (re-grant the peer + remove the bad admin); it can't steal
2653        // ownership or leak data. The bulletproof fix (verifiable removal: followers reject a snapshot that drops
2654        // a member the re-founder doesn't outrank) is deferred. Needs the RAW local key (ECDH), so no bunker.
2655        let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("a base rotation (privatize / private-ban read-cut) requires a local key (bunker/NIP-46 accounts can't rekey)")?;
2656        let owner = proven_owner_hex(community);
2657        let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
2658        if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::BAN) {
2659            return Err("not authorized to rotate the server root (no BAN)".to_string());
2660        }
2661
2662        // Derive prev_epoch / prev_commit / the rekey ENVELOPE root from the FRESHEST base state, never a
2663        // possibly-stale caller struct: addressing a rotation under a root that's already been superseded (e.g.
2664        // a re-founder re-rotating from a pre-convergence in-memory struct) lands it at a pseudonym converged
2665        // members never query → a base re-fork with no past-epoch heal to recover it. Reload first (mirrors
2666        // run_read_cut's freshest-epoch read); all downstream uses (envelope, re-anchor fetch) then agree.
2667        let fresh = crate::db::community::load_community(&community.id)?
2668            .ok_or("community gone before base rotation")?;
2669        let community = &fresh;
2670        let prev_epoch = community.server_root_epoch;
2671        let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("server-root epoch overflow")?);
2672        // Commit to the PRIOR root (the chain link the apply side verifies against `held_epoch_key(prev)`).
2673        let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, community.server_root_key.as_bytes());
2674        // The fresh server root — minted ONCE + archived, reused on a retry (fork-safety). Zeroized on drop.
2675        let new_root = mint_or_reuse_rotation_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2676        emit_rekey_progress("Rerolling community keys...", 5);
2677
2678        // ACQUIRE-BEFORE-COMMIT: do EVERY fetch the re-founding needs BEFORE publishing anything. The only
2679        // mid-rekey fetch is the re-anchor (the current control plane → re-wrapped under the new epoch); its
2680        // coverage gate (a head not fetchable) is exactly what stranded a published base rekey when it ran AFTER
2681        // the publish. Fetch + seal it now, so a transient miss aborts the whole re-founding with ZERO published
2682        // state. Only the publishes below need retry logic. The sealed editions are sent in the commit phase.
2683        let sealed = prepare_reanchor_control_plane(transport, community, &new_root, new_epoch).await?;
2684
2685        let total_recipients = (recipients.len() + 1).max(1); // recipients + me (multi-device)
2686        let mut seen = std::collections::HashSet::new();
2687        let mut blobs = Vec::new();
2688        for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
2689            if !seen.insert(pk.to_hex()) {
2690                continue;
2691            }
2692            blobs.push(super::rekey::build_rekey_blob(
2693                my_keys.secret_key(), pk, super::derive::RekeyScope::ServerRoot, new_epoch, &new_root,
2694            )?);
2695            emit_rekey_progress(
2696                &format!("Preparing keys for members ({}/{})...", blobs.len(), total_recipients),
2697                (5 + 35 * blobs.len() / total_recipients) as u8,
2698            );
2699        }
2700
2701        // COMMIT phase (publishes only — all fetching is done above). Publish the base rekey (delivers the new
2702        // root to recipients), SPLIT across ≤MAX_REKEY_BLOBS-blob events so a large recipient set rotates
2703        // in multiple 64KB-safe events at one address.
2704        emit_rekey_progress("Sending keys to members...", 42);
2705        publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
2706            super::rekey::build_server_root_rekey_event(
2707                &Keys::generate(), &my_keys, community.server_root_key.as_bytes(), &community.id,
2708                new_epoch, prev_epoch, &prev_commit, chunk,
2709            )
2710        })
2711        .await?;
2712
2713        // RE-FOUND BY COMPACTION: publish the pre-sealed snapshot (the current folded state re-wrapped as
2714        // editions under the new epoch) so a post-rotation joiner reaches the new root with reachable authority.
2715        // Gate the head-advance on EVERY edition landing (O(entities), tiny): a single un-ACKed edition aborts,
2716        // head-not-advanced is the safe side. A failed publish leaves the base rekey on relays while our head
2717        // stays put; a retry REUSES the archived root via `mint_or_reuse_rotation_key`, recomputing `new_epoch`
2718        // from the DB head — no same-epoch fork, idempotent re-publish. (The fetch can no longer fail here: the
2719        // snapshot was acquired up front, so this commit phase is publish-retry territory only.)
2720        let snapshot = publish_reanchor_snapshot(transport, &community.relays, sealed).await?;
2721        if snapshot.iter().any(|e| !e.published) {
2722            return Err(
2723                "re-founding aborted: a snapshot edition did not land (rate-limited / unreachable relay?); base head NOT advanced".to_string()
2724            );
2725        }
2726        emit_rekey_progress("Finalizing...", 98);
2727        // Only now commit: the new root is on relays AND the compacted plane is reachable at the new epoch.
2728        crate::db::community::advance_server_root_epoch(&cid, new_epoch.0, &new_root)?;
2729        // Record our carried heads at the (now-committed) new epoch so a subsequent edit chains from them, not
2730        // the abandoned old-epoch chain. The head is re-wrapped VERBATIM, so its version is preserved; epoch is
2731        // primary, so it supersedes the prior epoch's head regardless of version.
2732        for e in &snapshot {
2733            crate::db::community::set_edition_head_with_id(&cid, &e.entity_hex, e.version, &e.self_hash, &e.inner_id)?;
2734        }
2735        Ok(new_epoch.0)
2736    })
2737    .await
2738}
2739
2740/// Re-anchor the control plane after a base rotation: re-post the current control HEADS under the NEW
2741/// epoch's server-root pseudonym, so a post-rotation joiner (who holds only the new root) reaches current
2742/// authority with the one control-plane query they can make. Returns the per-entity snapshot it published.
2743///
2744/// **Re-WRAP, not re-sign.** Each edition's inner is the original real-npub-signed event — its signature,
2745/// version, and (community-scoped, rotation-stable) `entity_id` are all preserved; only the outer envelope
2746/// is fresh (new-root encryption + new-epoch `control_pseudonym` + ephemeral signer). Anyone can re-wrap
2747/// because the inner signature is what verifies — so the owner deed (carried inside the GroupRoot head) and
2748/// every grant's original granter survive untouched, which is what lets any BAN-holder re-found without
2749/// re-authoring or demoting anyone.
2750///
2751/// **COMPACTION: re-posts only the per-entity HEAD, not the whole `v1..vN` chain.** Cost is O(entities),
2752/// not O(history) — the fix for the original full-chain re-anchor, which failed once relays dropped old
2753/// editions or rate-limited the burst. The head is carried VERBATIM (keeps its real version number), so at
2754/// the new epoch its `prev_hash` dangles; that's fine because epoch-primary floors put a following member
2755/// in BOOTSTRAP mode for the new epoch (floor 0), where `fold_roster` surfaces the head via `bootstrap_head`
2756/// (Policy B) + the authority gate — no contiguous `v1..vN` is needed. (The old chain stays orphaned at the
2757/// prior epoch.) Only the freshest editions (the heads) need to be fetchable, sidestepping the dropped-old-
2758/// version wall.
2759///
2760/// **SCOPE: every tracked control entity** (GroupRoot, ChannelMetadata, roles, grants, the banlist) — built
2761/// from `get_all_edition_heads_epoched` and matched to its fetched raw edition; a head we can't fetch ABORTS
2762/// the rotation (better than stranding members on a thinned plane).
2763///
2764/// PRECONDITION: call this while `community` still holds the CURRENT (pre-rotation) root/epoch — it
2765/// fetches the current plane and re-posts under the new one. Running it after the head advanced would
2766/// fetch the (empty) new-epoch plane and re-anchor nothing.
2767///
2768/// `pub(crate)` + part of the base-rotation orchestration (#4e-2 sequences rekey → re-anchor → advance,
2769/// gating the head-advance on a successful re-anchor); `std::sync::Arc<crate::db::Session>`-gated across the fetch + each
2770/// publish (publish-only — no local DB write, so a mid-loop swap is not a cross-account hazard).
2771// Reached in production via `rotate_server_root` (the privatize re-founding path); also tested directly.
2772/// One re-wrapped entity head in a re-founding snapshot: its coordinate + (version, self_hash, inner_id)
2773/// of the head carried forward (for recording at the new epoch), and whether its publish landed.
2774pub(crate) struct SnapshotEntry {
2775    pub entity_hex: String,
2776    pub version: u64,
2777    pub self_hash: [u8; 32],
2778    pub inner_id: [u8; 32],
2779    pub published: bool,
2780}
2781
2782/// ACQUIRE half of the re-anchor (acquire-before-commit): fetch the current control plane and re-wrap every
2783/// entity head under the new root/epoch — but publish NOTHING. Returns the sealed editions ready to send.
2784/// The coverage gate (a head not fetchable ABORTS) lives here, so it trips BEFORE any rekey is published —
2785/// a transient fetch miss then aborts the whole re-founding with ZERO published state (clean retry), instead
2786/// of stranding a published base rekey with a half-anchored plane. See `rotate_server_root` for the ordering.
2787pub(crate) async fn prepare_reanchor_control_plane<T: Transport + ?Sized>(
2788    transport: &T,
2789    community: &Community,
2790    new_root: &[u8; 32],
2791    new_epoch: super::Epoch,
2792) -> Result<Vec<(Event, SnapshotEntry)>, String> {
2793    crate::db::scoped(async move {
2794        let cid = community.id.to_hex();
2795
2796        // RE-FOUND BY COMPACTION: re-wrap each entity's CURRENT HEAD verbatim under the new epoch — ONE
2797        // edition per entity, not the O(history) chain. "Re-wrap, not re-sign": the inner real-npub signature
2798        // (and the owner deed riding inside the GroupRoot content) are carried UNCHANGED, so every grant keeps
2799        // its ORIGINAL granter and authority re-derives identically at the new epoch. That's what lets ANY
2800        // BAN-holder re-found without demoting peer admins or touching ownership — the re-founder only re-keys
2801        // + re-addresses, never re-authors. Only the HEADS are needed (the freshest, most-retained editions),
2802        // so this sidesteps the unfetchable-old-version wall that broke the full-history re-anchor.
2803        let z = super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
2804        // Full evidence: this is the re-founding's acquire-before-commit coverage
2805        // gate — a floored head missing from the union ABORTS, so the union must be
2806        // the completest the reachable relays allow (a partial view = spurious abort).
2807        let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], evidence: Evidence::Full, ..Default::default() };
2808        let outers = transport.fetch(&query, &community.relays).await?;
2809        // self_hash → (raw inner edition opened under the CURRENT root, its inner_id).
2810        let mut by_hash: std::collections::HashMap<[u8; 32], (Event, [u8; 32])> = std::collections::HashMap::new();
2811        for outer in &outers {
2812            if let Ok(inner) = super::roster::open_control_edition(outer, &community.server_root_key) {
2813                if let Ok(parsed) = super::edition::parse_edition_inner(&inner) {
2814                    by_hash.insert(parsed.self_hash, (inner, parsed.inner_id));
2815                }
2816            }
2817        }
2818
2819        // Each entity's CURRENT head (the floors recorded at the current epoch) → re-wrap that exact edition
2820        // verbatim under the new root/epoch. A head we can't fetch ABORTS (better than stranding members on a
2821        // plane missing an entity); heads are the freshest editions, so the relay union almost always has them.
2822        let new_root_key = super::ServerRootKey(*new_root);
2823        let mut sealed: Vec<(Event, SnapshotEntry)> = Vec::new();
2824        for (entity_hex, (epoch, version, self_hash)) in crate::db::community::get_all_edition_heads_epoched(&cid)? {
2825            if epoch != community.server_root_epoch.0 {
2826                continue; // only the current founding's heads (a stale prior-epoch head is already superseded)
2827            }
2828            let (inner, inner_id) = by_hash.get(&self_hash).ok_or_else(|| {
2829                format!("re-founding aborted: head edition for entity {entity_hex} (v{version}) not fetchable — aborting so no member is stranded")
2830            })?;
2831            let outer = super::roster::seal_control_edition(&Keys::generate(), inner, &new_root_key, &community.id, new_epoch)?;
2832            sealed.push((outer, SnapshotEntry { entity_hex, version, self_hash, inner_id: *inner_id, published: false }));
2833        }
2834        Ok(sealed)
2835    })
2836    .await
2837}
2838
2839/// COMMIT half of the re-anchor: publish the (already-fetched + sealed) snapshot editions. Publishing only —
2840/// no fetch — so the caller's acquire phase guarantees there's nothing left that could fail-to-fetch here.
2841/// Each `published` flag reports whether that edition landed; the caller gates the head-advance on all true.
2842pub(crate) async fn publish_reanchor_snapshot<T: Transport + ?Sized>(
2843    transport: &T,
2844    relays: &[String],
2845    sealed: Vec<(Event, SnapshotEntry)>,
2846) -> Result<Vec<SnapshotEntry>, String> {
2847    // Publish THROTTLED (a bounded window, not an all-at-once burst) so the snapshot survives rate-limited
2848    // relays — the 0/N stall that the old concurrent re-anchor hit. Volume is O(entities), so this is small.
2849    use futures_util::stream::StreamExt;
2850    let total = sealed.len().max(1);
2851    let done = std::sync::atomic::AtomicUsize::new(0);
2852    let done_ref = &done;
2853    emit_rekey_progress(&format!("Re-founding community (0/{total})..."), 50);
2854    let out: Vec<SnapshotEntry> = futures_util::stream::iter(sealed.into_iter().map(|(ev, mut entry)| async move {
2855        entry.published = transport.publish_durable(&ev, relays).await.is_ok();
2856        let n = done_ref.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
2857        emit_rekey_progress(&format!("Re-founding community ({n}/{total})..."), (50 + 45 * n / total) as u8);
2858        entry
2859    }))
2860    .buffer_unordered(4)
2861    .collect()
2862    .await;
2863    Ok(out)
2864}
2865
2866/// Re-anchor in one shot (fetch + seal + publish). Test-only convenience; production splits the two halves
2867/// (`prepare_*` then `publish_*`) so the fetch precedes any rekey publish (acquire-before-commit).
2868#[cfg(test)]
2869pub(crate) async fn reanchor_control_plane<T: Transport + ?Sized>(
2870    transport: &T,
2871    community: &Community,
2872    new_root: &[u8; 32],
2873    new_epoch: super::Epoch,
2874) -> Result<Vec<SnapshotEntry>, String> {
2875    let sealed = prepare_reanchor_control_plane(transport, community, new_root, new_epoch).await?;
2876    publish_reanchor_snapshot(transport, &community.relays, sealed).await
2877}
2878
2879/// Apply a received, already-opened SERVER-ROOT (base) Rekey for `community` — the base counterpart to
2880/// [`apply_channel_rekey`]. Verifies the rotator's server-wide rotation authority (`BAN`, "role-based,
2881/// not owner-only"), checks continuity against the held prior ROOT (when held), finds + opens MY
2882/// ServerRoot-scope blob, and commits the new root via the atomic base head+archive write. The new root
2883/// reaches me ONLY through my ECDH blob — if I was removed in this rotation I find no blob
2884/// (`NotARecipient`) and recover nothing. `std::sync::Arc<crate::db::Session>`-gated; synchronous (one guard + the write
2885/// re-check suffice, same as `apply_channel_rekey`).
2886pub fn apply_server_root_rekey(
2887    community: &Community,
2888    parsed: &super::rekey::ParsedRekey,
2889) -> Result<RekeyOutcome, String> {
2890    let session = crate::db::current_session();
2891
2892    // Scope must be the server root (a Channel rekey is the other path).
2893    if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
2894        return Err("not a server-root rekey (channel rekeys use apply_channel_rekey)".to_string());
2895    }
2896    let cid = community.id.to_hex();
2897
2898    // a re-founding cannot cross a tombstone. Once dissolved, a base rekey is a "subsequent control
2899    // event" → refuse to advance the epoch (a rekey after a tombstone is invalid).
2900    //
2901    // MIGRATION EXEMPTION: a v1→v2 migration tombstone seals the community AND carries the v2
2902    // keys (`m`) sealed under the PUBLISH-time root. A member stale by ≥1 base epoch at publish holds
2903    // an older root and cannot open `m` until they walk forward — but the seal would normally block
2904    // that walk, permanently stranding them. Allow the base epoch to advance ONLY while a migration
2905    // pointer is held, the flip hasn't happened (`migrated_to` unset), and the target epoch does not
2906    // exceed the publish epoch the pointer names. Never weakens the post-flip fence (gated on
2907    // `migrated_to`) and never lets a plain dissolution advance (gated on the pointer's presence).
2908    if crate::db::community::get_community_dissolved(&cid)? && !super::migration::catchup_exempt(&cid, parsed.new_epoch.0) {
2909        return Err("community is dissolved; base epoch cannot advance".to_string());
2910    }
2911
2912    // Authority: a server-wide rotation is gated on BAN (owner supreme). The deed-derived `owner` is
2913    // the chain root — a community whose deed is missing/stripped yields `owner = None`, so NO rotator
2914    // authorizes (a deedless re-founding is followed by no one). A roster-read failure degrades to
2915    // owner-only (fail-closed): a stale/unreadable roster only UNDER-authorizes a non-owner, never over-.
2916    // Version-pinned rotator authority (spec §6 rule 1) is deferred, as on the channel path; the
2917    // banlist-precedence gate + the heal's deauthorized-root abandonment are the implemented mitigations.
2918    let owner = proven_owner_hex(community);
2919    let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
2920        crate::log_warn!("base rekey apply: roster read failed ({e}); authorizing owner only");
2921        Default::default()
2922    });
2923    if !rotator_is_authorized(&cid, &roster, owner.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
2924        return Err("base rekey rotator lacks server-wide rotation authority (BAN)".to_string());
2925    }
2926
2927    // Chain continuity: if I hold the prior ROOT and its commitment mismatches, I'm on a LOSING fork of
2928    // that epoch (a concurrent re-founding I lost) while this rekey extends the WINNING fork. As with channel
2929    // rekeys, that is NOT a foreign chain — the rotator is authority-verified (BAN) above and the ECDH blob
2930    // below proves it's addressed to ME — so ADOPT it (converge forward / reorg onto the authorized chain)
2931    // rather than reject and strand myself on the dead fork, which would stall every later base rotation too.
2932    // Replays of OLD epochs can't reach here: the forward walk only fetches epochs past my head.
2933    if let Some(prev_root) =
2934        crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, parsed.prev_epoch.0)?
2935    {
2936        if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_root) != parsed.prev_key_commitment {
2937            crate::log_warn!(
2938                "base rekey to epoch {} cites a prior-root I don't hold (I'm on a losing fork of epoch {}) — converging forward onto the authorized chain",
2939                parsed.new_epoch.0, parsed.prev_epoch.0
2940            );
2941        }
2942    }
2943
2944    // Find + open MY blob (ServerRoot scope).
2945    let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local identity to open the base rekey blob")?;
2946    let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
2947    let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
2948    let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
2949        Some(b) => b,
2950        None => return Ok(RekeyOutcome::NotARecipient),
2951    };
2952    let new_root =
2953        super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
2954
2955    if !session.is_live() {
2956        return Err("session changed during base rekey apply".to_string());
2957    }
2958    let head_advanced = crate::db::community::advance_server_root_epoch(&cid, parsed.new_epoch.0, &new_root)?;
2959    Ok(RekeyOutcome::Applied { head_advanced })
2960}
2961
2962/// How many candidate epochs the catch-up scan derives + fetches per round. All rekey pseudonyms are
2963/// server-root-derived, so a member computes the whole window up front and fetches it in ONE batched
2964/// `#z` REQ (not a sequential walk). One round covers up to this many missed rotations.
2965const REKEY_CATCHUP_WINDOW: u64 = 64;
2966/// Backstop on catch-up rounds — bounds an endless slide (e.g. a relay fabricating contiguous rekeys).
2967/// At `REKEY_CATCHUP_WINDOW` epochs/round this still covers thousands of real rotations before bailing.
2968const MAX_REKEY_CATCHUP_ROUNDS: usize = 64;
2969
2970/// Converge a SET of held channel epochs to the deterministic LOWEST authorized key on the wire (the
2971/// concurrent-rekey tiebreak), in ONE batched fetch per held server root. Two MANAGE_CHANNELS holders can rotate an epoch
2972/// with different keys (a concurrent-rekey fork); both forked rekeys collide under the PRIOR (shared) server
2973/// root, so search every held root, peek the key each delivers to ME, and adopt the lowest. Heals the head,
2974/// any epoch reorged THIS sync, AND the recent window of held epochs — the last covers a member that reorged
2975/// its head under an EARLIER build (so the in-sync forked-epoch set was never populated) yet still sits on a
2976/// losing sibling at a past epoch whose messages would otherwise stay unreadable.
2977///
2978/// Converge DOWN only: a held epoch is re-keyed only to a sibling STRICTLY lower than the key it already
2979/// holds, so a flaky round that returns just the higher sibling can't re-fork a converged epoch. Epochs I do
2980/// NOT hold are left to the gap-fill / forward walk (recovery via `apply`, not a same-epoch swap).
2981#[allow(clippy::too_many_arguments)]
2982async fn heal_channel_fork_epochs<T: Transport + ?Sized>(
2983    transport: &T,
2984    community: &Community,
2985    channel_id: &super::ChannelId,
2986    cid: &str,
2987    channel_hex: &str,
2988    epochs: &std::collections::BTreeSet<u64>,
2989    server_roots: &[[u8; 32]],
2990    session: &std::sync::Arc<crate::db::Session>,
2991) -> Result<(), String> {
2992    if epochs.is_empty() {
2993        return Ok(());
2994    }
2995    let owner_hex = proven_owner_hex(community);
2996    let roster = crate::db::community::get_community_roles(cid).unwrap_or_default();
2997    // Batched fetch: every target epoch's rekey under each held root, ONE query per root (mirrors the
2998    // forward walk). Track the lowest key delivered to ME by an authorized rotator, per epoch.
2999    let mut winner: std::collections::BTreeMap<u64, [u8; 32]> = std::collections::BTreeMap::new();
3000    for sr in server_roots {
3001        let z_tags: Vec<String> = epochs
3002            .iter()
3003            .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
3004            .collect();
3005        let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3006        for ev in transport.fetch(&q, &community.relays).await.unwrap_or_default() {
3007            let Ok(p) = super::rekey::open_rekey_event(&ev, sr) else { continue };
3008            if !matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) || !epochs.contains(&p.new_epoch.0) {
3009                continue;
3010            }
3011            if !rotator_is_authorized(cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::MANAGE_CHANNELS) {
3012                continue;
3013            }
3014            let Some(key) = peek_my_channel_key(&p) else { continue }; // not a recipient of this candidate
3015            winner.entry(p.new_epoch.0).and_modify(|best| { if key < *best { *best = key; } }).or_insert(key);
3016        }
3017    }
3018    for (epoch, win_key) in winner {
3019        if !session.is_live() {
3020            return Err("session changed during channel convergence".to_string());
3021        }
3022        // Only re-converge an epoch I ALREADY hold, and only DOWNWARD.
3023        // ACCEPTED MVP LIMITATION (GROUP_PROTOCOL.md): adoption checks blob-opens + authority, NOT that
3024        // the key decrypts extant messages — so a malicious MANAGE_CHANNELS holder can darken a settled past
3025        // epoch with a fresh lower key. Data-availability only, trusted-admin only; content-bind hardening deferred.
3026        if let Ok(Some(cur)) = crate::db::community::held_epoch_key(cid, channel_hex, epoch) {
3027            if win_key < cur {
3028                // `false` = the channel head moved off `epoch` between read and write (benign race); trace it
3029                // so a fork that keeps failing to converge is diagnosable in the field without changing flow.
3030                match crate::db::community::converge_channel_epoch(cid, channel_hex, epoch, &win_key) {
3031                    Ok(false) => crate::log_trace!("channel heal: converge of epoch {epoch} did not apply (head moved)"),
3032                    Err(e) => crate::log_trace!("channel heal: converge of epoch {epoch} errored: {e}"),
3033                    Ok(true) => {}
3034                }
3035            }
3036        }
3037    }
3038    Ok(())
3039}
3040
3041/// Catch a channel up to the latest epoch it is still a recipient of (windowed scan): fetch every
3042/// rekey published since our held epoch and apply the chain. Returns the channel's new current epoch.
3043/// Idempotent + cheap on the steady state (no new rotations → one empty-window fetch → returns the
3044/// held epoch). 3303s are addressed by the server-root-derived `rekey_pseudonym`, so this is a SEPARATE
3045/// fetch from the channel message plane (the exception).
3046///
3047/// **Removal is terminal.** Within a channel, the recipient set is forward-monotonic — once a member
3048/// is removed they are excluded from every later rotation, and re-addition is an out-of-band INVITE
3049/// that resets them to a fresh starter epoch (NOT something this scan discovers). So the walk stops at
3050/// the first `NotARecipient`: there is nothing legitimate past it for us. A *missing* intermediate
3051/// epoch (a relay-incomplete gap, where we ARE still a recipient on both sides) is logged and stepped
3052/// over (the hole stays unreadable until re-fetched from another relay), not treated as removal.
3053/// `std::sync::Arc<crate::db::Session>`-gated; applies in ascending epoch order so each rekey's prior-key continuity check
3054/// sees the key its predecessor just archived.
3055pub async fn catch_up_channel_rekeys<T: Transport + ?Sized>(
3056    transport: &T,
3057    community: &Community,
3058    channel_id: &super::ChannelId,
3059) -> Result<u64, String> {
3060    let session = crate::db::current_session();
3061    let server_root = community.server_root_key.as_bytes();
3062    let cid = community.id.to_hex();
3063    let channel_hex = channel_id.to_hex();
3064    // A channel rekey is addressed AND encrypted under whatever server root was current when it was
3065    // published — and the root itself ratchets on every base rotation. So derive + open the rekey window
3066    // under EVERY held server-root key, not just the current head: a channel rekey published under a prior
3067    // root is otherwise both unfindable (wrong pseudonym) and undecryptable, leaving permanent channel-key
3068    // gaps (and every message under those epochs stranded). We hold all prior roots in the epoch archive.
3069    let mut server_roots: Vec<[u8; 32]> = crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX)
3070        .unwrap_or_default()
3071        .into_iter()
3072        .map(|(_, k)| k)
3073        .collect();
3074    if !server_roots.iter().any(|r| r == server_root) {
3075        server_roots.push(*server_root); // ensure the current root is covered even if the archive lags
3076    }
3077    let mut head = community
3078        .channels
3079        .iter()
3080        .find(|c| &c.id == channel_id)
3081        .ok_or("channel not found in community")?
3082        .epoch
3083        .0;
3084
3085    // Past epochs I reorged through (applied a rekey whose cited prior key I don't hold — I'm on a losing
3086    // fork there). The forward walk converges my HEAD, but a forked PAST epoch keeps the wrong sibling's key
3087    // and its messages stay unreadable. Collect them here and re-converge each to the lowest sibling below.
3088    let mut forked_epochs: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
3089
3090    for _round in 0..MAX_REKEY_CATCHUP_ROUNDS {
3091        let window_top = head.saturating_add(REKEY_CATCHUP_WINDOW);
3092        // Derive + fetch the window under EACH held server root (a channel rekey lives under the root that
3093        // was current at its publish). Window × |held roots| is small and catch-up is rare; opening with
3094        // the SAME root that addressed each batch is unambiguous (a wrong root just fails the MAC).
3095        let mut parsed: Vec<super::rekey::ParsedRekey> = Vec::new();
3096        for sr in &server_roots {
3097            let z_tags: Vec<String> = (head.saturating_add(1)..=window_top)
3098                .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(e)).to_hex())
3099                .collect();
3100            let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3101            // Best-effort: a transient relay error on the forward-walk fetch must NOT abort the whole
3102            // catch-up (the caller ignores the Result), which would silently SKIP the current-head
3103            // convergence heal below — leaving a concurrent-rekey fork unhealed. Treat a failed fetch as
3104            // "no events here this round"; the next sync re-walks.
3105            for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
3106                if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
3107                    if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
3108                        parsed.push(p);
3109                    }
3110                }
3111            }
3112        }
3113        if parsed.is_empty() {
3114            break; // no rekey exists past `head` under any held root
3115        }
3116        // Apply in ascending epoch order (so each rekey's prior-key continuity sees its predecessor's key).
3117        parsed.sort_by_key(|p| p.new_epoch.0);
3118        let max_found = parsed.last().map(|p| p.new_epoch.0).unwrap_or(head);
3119
3120        let head_before = head;
3121        let mut removed = false;
3122        // GROUP BY EPOCH: a rotation may be SPLIT across multiple chunk events at the same address.
3123        // For each epoch try every chunk — Applied if ANY chunk holds my blob; "removed" only if a VALID
3124        // chunk said NotARecipient and none Applied (a NotARecipient on one chunk just means my blob is
3125        // in another). All-errored at an epoch = a gap (skip), not a removal.
3126        let mut by_epoch: std::collections::BTreeMap<u64, Vec<&super::rekey::ParsedRekey>> = std::collections::BTreeMap::new();
3127        for p in &parsed {
3128            by_epoch.entry(p.new_epoch.0).or_default().push(p);
3129        }
3130        for (e, chunks) in by_epoch {
3131            if !session.is_live() {
3132                return Err("session changed during rekey catch-up".to_string());
3133            }
3134            let mut applied = false;
3135            let mut saw_not_recipient = false;
3136            for p in &chunks {
3137                match apply_channel_rekey(community, p) {
3138                    Ok(RekeyOutcome::Applied { .. }) => {
3139                        applied = true;
3140                        break;
3141                    }
3142                    Ok(RekeyOutcome::NotARecipient) => saw_not_recipient = true,
3143                    Err(err) => crate::log_warn!("rekey catch-up: skipping epoch {e} chunk: {err}"),
3144                }
3145            }
3146            if applied {
3147                // Reorg detection: if this rekey continues from a prior epoch whose key I hold but whose
3148                // commitment mismatches, I just converged forward off a losing fork — that prior epoch is forked
3149                // and needs its own lowest-key heal (else its messages stay unreadable under the wrong sibling).
3150                // All chunks of one rotation carry IDENTICAL continuity fields (same prev_epoch + prev_commit —
3151                // they're the same rotation split across size-bounded events), so `first()` is representative.
3152                if let Some(p) = chunks.first() {
3153                    let pe = p.prev_epoch.0;
3154                    if let Ok(Some(prev_key)) = crate::db::community::held_epoch_key(&cid, &channel_hex, pe) {
3155                        if super::rekey::epoch_key_commitment(p.prev_epoch, &prev_key) != p.prev_key_commitment {
3156                            forked_epochs.insert(pe);
3157                        }
3158                    }
3159                }
3160                // A non-contiguous jump means intermediate epochs weren't recovered (a relay gap) —
3161                // surface the hole (that history stays unreadable until re-fetched).
3162                if e > head + 1 {
3163                    crate::log_warn!(
3164                        "rekey catch-up: channel epochs {}..={} not recovered (key gap; history unreadable until re-fetched)",
3165                        head + 1, e - 1
3166                    );
3167                }
3168                head = head.max(e);
3169            } else if saw_not_recipient {
3170                // A valid rotation at this epoch held no blob for me across ALL its chunks ⇒ I was removed
3171                // here. Forward-terminal (re-add is a fresh invite), so stop — nothing past it is ours.
3172                removed = true;
3173                break;
3174            }
3175            // else: all chunks at this epoch errored (gap/forged) — don't advance, don't remove.
3176        }
3177
3178        // Stop on removal (terminal), when a full round advanced nothing (only gaps/forged events — no
3179        // legit rekey for us here), or when the window wasn't saturated (we've reached the latest).
3180        if removed || head == head_before || max_found < window_top {
3181            break;
3182        }
3183    }
3184
3185    // BACKWARD gap-fill (heal): the forward walk above advances the HEAD and can leapfrog an epoch
3186    // whose rekey wasn't found (a prior catch-up that lacked the addressing root, or a relay miss). Those
3187    // holes are below `head`, so the forward window never revisits them — yet we're entitled to those
3188    // keys. Re-fetch each MISSING epoch's rekey under every held server root and apply it (archive-only:
3189    // `advance_channel_epoch` never regresses the head), so stranded history (messages under a skipped
3190    // epoch) becomes readable. Non-ratcheted keys make this pure random-access — no replay needed.
3191    let held: std::collections::HashSet<u64> = crate::db::community::held_epoch_keys(&cid, &channel_hex)
3192        .unwrap_or_default()
3193        .into_iter()
3194        .map(|(e, _)| e.0)
3195        .collect();
3196    let missing: Vec<u64> = (0..head).filter(|e| !held.contains(e)).collect();
3197    if !missing.is_empty() {
3198        for sr in &server_roots {
3199            if !session.is_live() {
3200                return Err("session changed during rekey gap-fill".to_string());
3201            }
3202            let z_tags: Vec<String> = missing
3203                .iter()
3204                .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
3205                .collect();
3206            let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3207            // Best-effort (same rationale as the forward walk): a relay error on a gap-fill fetch must not
3208            // abort before the convergence heal.
3209            for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
3210                if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
3211                    if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
3212                        let _ = apply_channel_rekey(community, &p); // archive-only for sub-head epochs
3213                    }
3214                }
3215            }
3216        }
3217    }
3218
3219    // CONCURRENT RE-FOUNDING HEAL: converge to the deterministic LOWEST authorized sibling at every epoch that
3220    // can be forked — the current HEAD (two MANAGE_CHANNELS holders rotated it concurrently with different
3221    // keys), every epoch I reorged through THIS sync, AND the recent window of held epochs. The window
3222    // pass heals a member that reorged its head under an EARLIER build (so `forked_epochs` was never populated
3223    // for it) yet still sits on a losing sibling at a past epoch — otherwise that epoch's messages stay
3224    // unreadable forever (the gap-fill skips it because a key IS held). One batched fetch per held root.
3225    if head > 0 && session.is_live() {
3226        let lo = head.saturating_sub(REKEY_CATCHUP_WINDOW).max(1);
3227        let mut epochs: std::collections::BTreeSet<u64> = (lo..=head).collect();
3228        epochs.append(&mut forked_epochs);
3229        let _ = heal_channel_fork_epochs(transport, community, channel_id, &cid, &channel_hex, &epochs, &server_roots, &session).await;
3230    }
3231    Ok(head)
3232}
3233
3234/// Backstop on base-rotation walk steps (base rotations are rare, so this far exceeds any real chain;
3235/// it bounds a hostile/fabricated chain — which already fails at `apply_server_root_rekey` anyway).
3236const MAX_BASE_CATCHUP_STEPS: usize = 256;
3237
3238/// Catch the SERVER ROOT up to its latest epoch — a FORWARD WALK (the base has no stable key above
3239/// it, so `base_rekey_pseudonym` is keyed by the PRIOR root). Each step: derive the next base rekey's
3240/// address from the root I currently hold, fetch it, open it under that root, apply it (recovering the
3241/// NEXT root), and repeat. Returns the new base epoch. One step per base rotation — bounded, and base
3242/// rotations are rare. Stops on a removal (`NotARecipient` — re-add is a fresh invite, not this walk),
3243/// when no further base rekey exists, or when a rekey can't be applied (can't get the next root).
3244///
3245/// After this advances the base epoch, the caller MUST resync the control plane at the NEW epoch
3246/// (`control_pseudonym(new_root, …)`) before trusting authority — the re-anchoring guarantees the
3247/// current heads are reachable there (#4e). This fn only recovers the base keys + advances the head.
3248/// B2 helper: open MY ServerRoot blob in `parsed` WITHOUT committing, to learn which new root this rotation
3249/// would deliver me. Lets [`catch_up_server_root`] pick the canonical rotation among concurrent re-foundings
3250/// before applying any. `Ok(None)` = I'm not a recipient of this rotation (or it's not a base rekey).
3251fn peek_my_server_root(parsed: &super::rekey::ParsedRekey) -> Result<Option<[u8; 32]>, String> {
3252    if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
3253        return Ok(None);
3254    }
3255    let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local key to open a base rekey blob")?;
3256    let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
3257    let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3258    let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
3259        Some(b) => b,
3260        None => return Ok(None),
3261    };
3262    super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).map(Some)
3263}
3264
3265/// Convergence helper: open MY Channel blob in `parsed` WITHOUT committing, to learn which new channel key this
3266/// rotation would deliver me. Lets the channel current-head heal pick a deterministic winner (lowest
3267/// delivered key) among concurrent same-epoch channel rotations. `None` = not a recipient / can't open.
3268fn peek_my_channel_key(parsed: &super::rekey::ParsedRekey) -> Option<[u8; 32]> {
3269    let my_keys = crate::state::MY_SECRET_KEY.to_keys()?;
3270    let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator).ok()?;
3271    let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3272    let mine = parsed.blobs.iter().find(|b| b.locator == my_locator)?;
3273    super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).ok()
3274}
3275
3276pub async fn catch_up_server_root<T: Transport + ?Sized>(
3277    transport: &T,
3278    community: &Community,
3279) -> Result<BaseCatchup, String> {
3280    let session = crate::db::current_session();
3281    let cid = community.id.to_hex();
3282    let mut head = community.server_root_epoch.0;
3283    // Set true if the walk stops because an AUTHORIZED base rotation EXCLUDED us (read-cut / private ban):
3284    // we hold the prior root, opened the rotation, its rotator held BAN per the roster we hold, but no chunk
3285    // carried our blob. The caller treats this as removal and erases local community data (the cut member
3286    // can't read the new banlist to learn it the normal way, so this is the catch-all removal signal).
3287    let mut removed = false;
3288    // The root I currently hold at `head` — drives the next step's address (prior-root-keyed) + opens it.
3289    let mut current_root: [u8; 32] = *community.server_root_key.as_bytes();
3290
3291    for _step in 0..MAX_BASE_CATCHUP_STEPS {
3292        let next = match head.checked_add(1) {
3293            Some(n) => n,
3294            None => break,
3295        };
3296        let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(current_root), &community.id, super::Epoch(next)).to_hex();
3297        let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
3298        let events = transport.fetch(&query, &community.relays).await?;
3299        if events.is_empty() {
3300            break; // no base rotation past `head`
3301        }
3302
3303        // Open under the root I hold; a base rotation at `next` may be SPLIT across chunk events at this
3304        // address, so collect ALL chunks for `next`.
3305        let chunks: Vec<super::rekey::ParsedRekey> = events
3306            .iter()
3307            .filter_map(|ev| super::rekey::open_rekey_event(ev, &current_root).ok())
3308            .filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == next)
3309            .collect();
3310        if chunks.is_empty() {
3311            break; // nothing valid for `next` under the root we hold
3312        }
3313
3314        if !session.is_live() {
3315            return Err("session changed during base rekey catch-up".to_string());
3316        }
3317
3318        // B2 — CONCURRENT RE-FOUNDING CONVERGENCE. There may be MORE than one rotation at `next` (two
3319        // BAN-holders re-founding at once, each delivering a DIFFERENT new root to the same observed set).
3320        // Every member must pick the SAME one or the community forks irrecoverably. Peek the root each
3321        // rotation would give me, then deterministically choose the LOWEST new-root bytes — convergent for
3322        // everyone who received both. (The root is the only member-computable rotation identity: the inner
3323        // event id of "my" chunk differs per member, since each member's blob sits in a different chunk, so
3324        // it can't be the tiebreak.) A member who only received the losing root heals on the next re-founding.
3325        //
3326        // AUTHORITY BEFORE THE TIEBREAK: only a BAN-holder's rotation is a candidate. A plain member
3327        // holds the prior root + can sign as rotator + build valid ECDH blobs, so without this gate they
3328        // could forge a byte-LOWER root that honest members would PICK as the winner and then fail to apply
3329        // (authority is also checked in apply) — stalling them at the prior epoch while others advance: a
3330        // permanent fork the heal can't recover. Gate here, before `min_by`, exactly like the current-head heal.
3331        let owner_hex = proven_owner_hex(community);
3332        let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3333        let mut candidates: Vec<(&super::rekey::ParsedRekey, [u8; 32])> = Vec::new();
3334        for parsed in &chunks {
3335            if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
3336                continue;
3337            }
3338            match peek_my_server_root(parsed) {
3339                Ok(Some(root)) => candidates.push((parsed, root)),
3340                Ok(None) => {}
3341                Err(err) => crate::log_warn!("base rekey catch-up: epoch {next} peek: {err}"),
3342            }
3343        }
3344        let applied = match candidates.into_iter().min_by(|a, b| a.1.cmp(&b.1)) {
3345            Some((parsed, _)) => match apply_server_root_rekey(community, parsed) {
3346                Ok(RekeyOutcome::Applied { .. }) => true,
3347                Ok(RekeyOutcome::NotARecipient) => false, // unreachable: peek already confirmed recipiency
3348                Err(err) => { crate::log_warn!("base rekey catch-up: epoch {next} apply: {err}"); false }
3349            },
3350            None => {
3351                // No chunk held my blob — I was excluded from this base rotation. If an AUTHORIZED rotator
3352                // (held BAN per the roster I STILL hold) performed it, this is a read-cut removing me →
3353                // signal removal so the caller erases. Verify authority so a non-BAN member who merely holds
3354                // the prior root can't forge an eviction event that tricks me into self-deleting.
3355                let owner = proven_owner_hex(community);
3356                let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3357                if chunks.iter().any(|p| rotator_is_authorized(&cid, &roster, owner.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)) {
3358                    removed = true;
3359                }
3360                false // removed from the base (terminal) → stop the walk
3361            }
3362        };
3363        if !applied {
3364            break;
3365        }
3366        // Recover the just-archived new root to address the next step.
3367        match crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, next)? {
3368            Some(root) => {
3369                current_root = root;
3370                head = next;
3371            }
3372            None => {
3373                // Shouldn't happen — apply archives the root before returning Applied. If it ever does,
3374                // a DB-archive invariant broke; stop rather than loop on a stale root.
3375                crate::log_warn!("base rekey catch-up: epoch {next} applied but its root is not archived; halting walk");
3376                break;
3377            }
3378        }
3379    }
3380
3381    // CONCURRENT RE-FOUNDING HEAL (current-head convergence): the forward walk only tiebreaks at head+1, so
3382    // two BAN-holders who re-founded at the SAME epoch each end on their OWN root and never reconcile each
3383    // other (only bystanders advancing INTO the epoch do). Re-fetch THIS epoch's base rekeys — they're all
3384    // at the one address keyed by the PRIOR root we still hold — and if an AUTHORIZED sibling delivers a
3385    // LOWER root than the one we hold, switch to it (the same lowest-root rule), then re-fold the control
3386    // plane under the adopted root. Convergent for everyone: the lowest root is the deterministic winner.
3387    if head > 0 && !removed {
3388        if let Ok(Some(prior_root)) = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, head - 1) {
3389            let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(prior_root), &community.id, super::Epoch(head)).to_hex();
3390            let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
3391            let events = transport.fetch(&query, &community.relays).await.unwrap_or_default();
3392            let chunks: Vec<super::rekey::ParsedRekey> = events
3393                .iter()
3394                .filter_map(|ev| super::rekey::open_rekey_event(ev, &prior_root).ok())
3395                .filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == head)
3396                .collect();
3397            let owner_hex = proven_owner_hex(community);
3398            let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3399            let mut best: Option<(&super::rekey::ParsedRekey, [u8; 32])> = None;
3400            for p in &chunks {
3401                // Only an AUTHORIZED re-founding (rotator held BAN, not banned) is a convergence
3402                // candidate — a non-BAN member who merely holds the prior root can't forge a lower
3403                // root to hijack the chain.
3404                if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN) {
3405                    continue;
3406                }
3407                if let Ok(Some(root)) = peek_my_server_root(p) {
3408                    if best.as_ref().is_none_or(|(_, br)| root < *br) {
3409                        best = Some((p, root));
3410                    }
3411                }
3412            }
3413            // Authority dominates the down-only rule: if the root I currently hold is POSITIVELY
3414            // identified as a since-deauthorized rotation (its chunk is on the wire, delivers my
3415            // current root, and its rotator now fails the authority/banlist gate), abandon it for
3416            // the lowest AUTHORIZED sibling even when that sibling is byte-higher. Without this, a
3417            // banned admin who raced their own removal with a ground-low re-founding root keeps
3418            // every member who adopted it partitioned forever — the heal would refuse to climb back
3419            // to the owner's legitimate (higher) root. Positive identification only: when the
3420            // current root's chunk is absent (withheld), keep the strict down-only rule so a flaky
3421            // round can't re-fork a converged epoch.
3422            let current_deauthorized = chunks.iter().any(|p| {
3423                matches!(peek_my_server_root(p), Ok(Some(r)) if r == current_root)
3424                    && !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)
3425            });
3426            if let Some((winner, win_root)) = best {
3427                let adopt = if current_deauthorized {
3428                    win_root != current_root
3429                } else {
3430                    win_root < current_root
3431                };
3432                if adopt {
3433                    if !session.is_live() {
3434                        return Err("session changed during base convergence".to_string());
3435                    }
3436                    // Adopt the winner: apply archives its root (no head advance at the same epoch), then
3437                    // `converge_server_root_epoch` swaps the head root, then re-fold control under it.
3438                    if apply_server_root_rekey(community, winner).is_ok() {
3439                        match crate::db::community::converge_server_root_epoch(&cid, head, &win_root) {
3440                            Ok(false) => crate::log_trace!("base heal: converge of epoch {head} did not apply (head moved)"),
3441                            Err(e) => crate::log_trace!("base heal: converge of epoch {head} errored: {e}"),
3442                            Ok(true) => {}
3443                        }
3444                        current_root = win_root;
3445                        if let Ok(Some(fresh)) = crate::db::community::load_community(&community.id) {
3446                            let _ = fetch_and_apply_control(transport, &fresh).await;
3447                        }
3448                    }
3449                }
3450            }
3451        }
3452    }
3453    let _ = current_root; // may be unused if no further steps read it
3454    Ok(BaseCatchup { epoch: head, removed })
3455}
3456
3457/// Outcome of [`catch_up_server_root`]: the base epoch reached, and whether an AUTHORIZED base rotation
3458/// EXCLUDED us (a read-cut / private ban). `removed` is the catch-all "you've been removed" signal for a
3459/// cryptographically cut member who can no longer read the banlist to learn it the normal way.
3460#[derive(Debug, Clone, Copy)]
3461pub struct BaseCatchup {
3462    pub epoch: u64,
3463    pub removed: bool,
3464}
3465
3466#[cfg(test)]
3467mod tests {
3468    use super::*;
3469    use crate::community::send::fetch_channel_messages;
3470    use crate::community::transport::{memory::MemoryRelay, Query, Transport};
3471    use nostr_sdk::prelude::{EventBuilder, Kind};
3472
3473    /// A transport whose publish always fails (fetch returns nothing) — for testing that
3474    /// a failed deletion publish doesn't strand the single-use key.
3475    struct FailingRelay;
3476    #[async_trait::async_trait]
3477    impl Transport for FailingRelay {
3478        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
3479        async fn publish(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
3480            Err("relay unreachable".to_string())
3481        }
3482        async fn publish_durable(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
3483            Err("relay unreachable".to_string())
3484        }
3485        async fn fetch(&self, _query: &Query, _relays: &[String]) -> Result<Vec<Event>, String> {
3486            Ok(Vec::new())
3487        }
3488    }
3489
3490    /// A relay that selectively fails REKEY (3303) publishes (toggleable), delegating everything else to
3491    /// an inner [`MemoryRelay`]. Lets a test make a re-seal's base rekey fail while the banlist edition
3492    /// still lands, then "fix" the relay and verify the read-cut retry recovers.
3493    struct RekeyFailingRelay {
3494        inner: MemoryRelay,
3495        fail_rekey: std::sync::atomic::AtomicBool,
3496    }
3497    impl RekeyFailingRelay {
3498        fn new() -> Self {
3499            Self { inner: MemoryRelay::new(), fail_rekey: std::sync::atomic::AtomicBool::new(true) }
3500        }
3501        fn allow_rekey(&self) {
3502            self.fail_rekey.store(false, std::sync::atomic::Ordering::Relaxed);
3503        }
3504        fn blocks(&self, event: &Event) -> bool {
3505            self.fail_rekey.load(std::sync::atomic::Ordering::Relaxed)
3506                && event.kind.as_u16() == crate::stored_event::event_kind::COMMUNITY_REKEY
3507        }
3508    }
3509    #[async_trait::async_trait]
3510    impl Transport for RekeyFailingRelay {
3511        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
3512        async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
3513            if self.blocks(event) { return Err("rekey relay down".to_string()); }
3514            self.inner.publish(event, relays).await
3515        }
3516        async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
3517            if self.blocks(event) { return Err("rekey relay down".to_string()); }
3518            self.inner.publish_durable(event, relays).await
3519        }
3520        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
3521            self.inner.fetch(query, relays).await
3522        }
3523    }
3524
3525    static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(5000);
3526
3527    fn make_test_npub(n: u32) -> String {
3528        const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3529        let mut payload = vec![b'q'; 58];
3530        let mut x = n as u64;
3531        let mut i = 58;
3532        while x > 0 && i > 0 {
3533            i -= 1;
3534            payload[i] = BECH32[(x as usize) % 32];
3535            x /= 32;
3536        }
3537        format!("npub1{}", std::str::from_utf8(&payload).unwrap())
3538    }
3539
3540    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
3541        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3542        crate::db::close_database();
3543        // Per-account row-id caches survive close_database; clear them so a stale entry from a prior
3544        // test's DB can't point into this fresh account's DB and FK-fail an insert.
3545        crate::db::clear_id_caches();
3546        // Drop any signer a prior test injected (see `simulate_bunker`) so it can't
3547        // sign for this one.
3548        crate::signer::set_test_signer(None);
3549        let tmp = tempfile::tempdir().unwrap();
3550        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3551        let account = make_test_npub(n);
3552        std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3553        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
3554        crate::db::set_current_account(account.clone()).unwrap();
3555        crate::db::init_database(&account).unwrap();
3556        // Clear any client a prior test installed — else `active_signer()` would prefer that stale
3557        // client's signer over this test's fresh local identity (cross-test contamination).
3558        let _ = crate::state::take_nostr_client();
3559        // A local owner identity so create_community can sign the (now mandatory) owner attestation.
3560        let owner = Keys::generate();
3561        crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
3562        crate::state::set_my_public_key(owner.public_key());
3563        (tmp, guard)
3564    }
3565
3566    #[test]
3567    fn community_cap_rejects_a_new_membership_at_the_limit() {
3568        let (_tmp, _guard) = init_test_db();
3569        let mk = |i: usize| {
3570            let id = format!("{:064x}", i);
3571            crate::community::list::CommunityListEntry {
3572                community_id: id.clone(),
3573                seed: crate::community::invite::CommunityInvite {
3574                    community_id: id,
3575                    name: String::new(),
3576                    server_root_key: String::new(),
3577                    server_root_epoch: 0,
3578                    relays: vec![],
3579                    channels: vec![],
3580                    owner_attestation: None,
3581                    icon: None,
3582                },
3583                current: None,
3584                added_at: 0,
3585            }
3586        };
3587        let mut list = crate::community::list::CommunityList::default();
3588        for i in 0..(MAX_COMMUNITIES - 1) {
3589            list.entries.push(mk(i));
3590        }
3591        crate::db::settings::set_sql_setting("community_list_json".to_string(), list.to_json()).unwrap();
3592        assert!(enforce_community_cap().is_ok(), "under the cap a new join is allowed");
3593
3594        list.entries.push(mk(MAX_COMMUNITIES - 1)); // now exactly MAX_COMMUNITIES
3595        crate::db::settings::set_sql_setting("community_list_json".to_string(), list.to_json()).unwrap();
3596        assert!(enforce_community_cap().is_err(), "at the cap a new join is rejected");
3597    }
3598
3599    // --- apply_channel_rekey (#3c) ---
3600
3601    /// Build + persist a member-view community whose proven owner is `owner` (attestation signed by
3602    /// them), archiving the genesis epoch-0 channel key + server root via save_community.
3603    fn saved_community_owned_by(owner: &Keys) -> Community {
3604        let mut community = Community::create("HQ", "general", vec!["r".into()]);
3605        let cid = community.id.to_hex();
3606        community.owner_attestation = Some(
3607            crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
3608                .finalize(owner)
3609                .unwrap()
3610                .as_json(),
3611        );
3612        crate::db::community::save_community(&community).unwrap();
3613        community
3614    }
3615
3616    /// An in-memory owner-attested Community signed by the SEEDED local identity (so `is_proven_owner`
3617    /// is true and owner-gated actions like `create_public_invite` pass). NOT saved to the DB — for
3618    /// tests where the same single DB later plays the joiner.
3619    fn attested_community(name: &str, channel: &str, relays: Vec<String>) -> Community {
3620        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
3621        let mut community = Community::create(name, channel, relays);
3622        community.owner_attestation = Some(
3623            crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &community.id.to_hex())
3624                .finalize(&owner).unwrap().as_json(),
3625        );
3626        community
3627    }
3628
3629    /// Set the local identity (the rekey recipient in these tests).
3630    fn become_local(me: &Keys) {
3631        crate::state::MY_SECRET_KEY.store_from_keys(me, &[]);
3632        crate::state::set_my_public_key(me.public_key());
3633    }
3634
3635    /// An owner-authored channel rekey to `new_epoch` carrying one blob for `recipient_pk`, citing the
3636    /// genesis epoch-0 key as `prev`. Returns the opened ParsedRekey ready for apply.
3637    fn owner_channel_rekey(
3638        owner: &Keys,
3639        community: &Community,
3640        recipient_pk: &nostr_sdk::prelude::PublicKey,
3641        new_epoch: u64,
3642        new_key: &[u8; 32],
3643    ) -> super::super::rekey::ParsedRekey {
3644        let chan = &community.channels[0];
3645        let scope = super::super::derive::RekeyScope::Channel(chan.id);
3646        let blob = super::super::rekey::build_rekey_blob(
3647            owner.secret_key(), recipient_pk, scope, crate::community::Epoch(new_epoch), new_key,
3648        )
3649        .unwrap();
3650        let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes());
3651        let outer = super::super::rekey::build_channel_rekey_event(
3652            &Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
3653            crate::community::Epoch(new_epoch), crate::community::Epoch(0), &commit, &[blob],
3654        )
3655        .unwrap();
3656        super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
3657    }
3658
3659    /// Transport-unified outer dedup: a wire event we've already persisted (its outer id recorded as
3660    /// the inner's `wrapper_event_id`) is dropped BEFORE decryption on a re-fetch — the same contract
3661    /// DM gift-wraps get from the wrapper-id layer. This is what keeps a boot/catch-up sweep's re-fetch
3662    /// of the whole channel page from re-ingesting or re-emitting events we already hold.
3663    #[tokio::test]
3664    async fn outer_event_dedup_skips_an_already_persisted_wire_event() {
3665        let (_tmp, _guard) = init_test_db();
3666        let owner = Keys::generate();
3667        let me = Keys::generate();
3668        become_local(&me);
3669        let community = saved_community_owned_by(&owner);
3670        let channel = community.channels[0].clone();
3671        let chan_hex = channel.id.to_hex();
3672
3673        // A real wire event (stable outer id) authored by a keyholding member.
3674        let author = Keys::generate();
3675        let outer = crate::community::envelope::seal_message(
3676            &author, &channel.key, &channel.id, channel.epoch, "gm", 1000,
3677        ).unwrap();
3678        let outer_hex = outer.id.to_hex();
3679
3680        // First sight: ingests, and the inner records its OUTER wire id as the wrapper link.
3681        let mut state = crate::state::ChatState::new();
3682        let msg = match crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key()) {
3683            Some(crate::community::inbound::IncomingEvent::NewMessage(m)) => m,
3684            _ => panic!("expected NewMessage from a fresh wire event"),
3685        };
3686        assert_eq!(msg.wrapper_event_id.as_deref(), Some(outer_hex.as_str()),
3687            "the inner must carry its outer wire id as wrapper_event_id");
3688
3689        // Persist exactly as the sweep does (writes wrapper_event_id into the events table).
3690        crate::db::events::save_message(&chan_hex, &msg).await.unwrap();
3691
3692        // Re-fetch / relay redelivery of the SAME wire event → dropped before decryption.
3693        let mut state2 = crate::state::ChatState::new();
3694        let second = crate::community::inbound::process_incoming(&mut state2, &outer, &channel, &me.public_key());
3695        assert!(second.is_none(), "an already-processed wire event must dedup before decryption");
3696    }
3697
3698    /// The dedup ledger is shared across transports, but NIP-77 negentropy must fingerprint ONLY the
3699    /// gift-wrap ('nip17') subset — a Concord wrapper in the DM reconciliation set would bloat and skew it.
3700    #[tokio::test]
3701    async fn ledger_is_shared_but_negentropy_stays_nip17_only() {
3702        let (_tmp, _guard) = init_test_db();
3703        let dm = [0xA1u8; 32];
3704        let concord = [0xC0u8; 32];
3705        crate::db::wrappers::save_processed_wrapper(&dm, 100, crate::db::wrappers::TRANSPORT_NIP17).unwrap();
3706        crate::db::wrappers::save_processed_wrapper(&concord, 200, crate::db::wrappers::TRANSPORT_CONCORD).unwrap();
3707
3708        // The dedup ledger sees BOTH transports.
3709        assert!(crate::db::wrappers::processed_wrapper_exists(&dm));
3710        assert!(crate::db::wrappers::processed_wrapper_exists(&concord));
3711
3712        // NIP-77 fingerprints only the gift-wrap subset — Concord never leaks into DM sync.
3713        let items = crate::db::wrappers::load_negentropy_items().unwrap();
3714        assert_eq!(items.len(), 1, "negentropy must exclude concord wrappers");
3715        assert_eq!(items[0].0.to_bytes(), dm);
3716    }
3717
3718    /// A non-message sub-kind (presence) has no inner row to carry a wrapper_event_id, so it records the
3719    /// outer id in the shared ledger at process time. A re-fetch then dedups it before decryption, just
3720    /// like a message — every sub-kind gets the same transport-level skip.
3721    #[tokio::test]
3722    async fn non_message_subkind_dedups_via_the_shared_ledger() {
3723        let (_tmp, _guard) = init_test_db();
3724        let owner = Keys::generate();
3725        let me = Keys::generate();
3726        become_local(&me);
3727        let community = saved_community_owned_by(&owner);
3728        let channel = community.channels[0].clone();
3729
3730        // A presence (3306) wire event from a member — a non-row sub-kind.
3731        let author = Keys::generate();
3732        let inner = super::super::envelope::build_inner_typed(
3733            author.public_key(), &channel.id, channel.epoch,
3734            crate::stored_event::event_kind::COMMUNITY_PRESENCE, "join", 5, None, &[],
3735        ).finalize(&author).unwrap();
3736        let outer = super::super::envelope::seal_with_signed_inner(
3737            &Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch,
3738        ).unwrap();
3739
3740        // First sight: a Presence outcome, and the outer id is recorded in the ledger.
3741        let mut state = crate::state::ChatState::new();
3742        let first = crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key());
3743        assert!(matches!(first, Some(crate::community::inbound::IncomingEvent::Presence { .. })),
3744            "expected a Presence outcome");
3745        assert!(crate::db::wrappers::processed_wrapper_exists(&outer.id.to_bytes()),
3746            "a non-message sub-kind must record its outer id in the shared ledger");
3747
3748        // Re-fetch of the same wire event → dropped before decryption.
3749        let second = crate::community::inbound::process_incoming(&mut crate::state::ChatState::new(), &outer, &channel, &me.public_key());
3750        assert!(second.is_none(), "a re-fetched presence must dedup via the shared ledger");
3751    }
3752
3753    #[test]
3754    fn apply_channel_rekey_recovers_and_advances_head() {
3755        let (_tmp, _guard) = init_test_db();
3756        let owner = Keys::generate(); // owner = rotator (supreme authority)
3757        let me = Keys::generate();
3758        become_local(&me);
3759        let community = saved_community_owned_by(&owner);
3760        let cid = community.id.to_hex();
3761        let chan_hex = community.channels[0].id.to_hex();
3762        let new_key = [0xCDu8; 32];
3763
3764        let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &new_key);
3765        let outcome = apply_channel_rekey(&community, &parsed).unwrap();
3766        assert_eq!(outcome, RekeyOutcome::Applied { head_advanced: true });
3767
3768        // Archive holds the new epoch-1 key, and the channel head advanced to it (epoch + key).
3769        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(new_key));
3770        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3771        assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
3772        assert_eq!(reloaded.channels[0].key.as_bytes(), &new_key);
3773        // The genesis epoch-0 key is RETAINED (cross-epoch history stays decryptable).
3774        assert!(crate::db::community::held_epoch_key(&cid, &chan_hex, 0).unwrap().is_some());
3775    }
3776
3777    #[test]
3778    fn apply_channel_rekey_accepts_matching_continuity() {
3779        // The happy continuity path: I HOLD the prior (genesis epoch-0) key and the rekey cites a
3780        // commitment over it → the fork-detection check passes and the rekey applies.
3781        let (_tmp, _guard) = init_test_db();
3782        let owner = Keys::generate();
3783        let me = Keys::generate();
3784        become_local(&me);
3785        let community = saved_community_owned_by(&owner);
3786        // owner_channel_rekey commits over the genesis epoch-0 key, which I hold (archived on save).
3787        let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
3788        assert_eq!(
3789            apply_channel_rekey(&community, &parsed).unwrap(),
3790            RekeyOutcome::Applied { head_advanced: true },
3791            "a rekey whose prior-key commitment matches the held genesis key applies"
3792        );
3793    }
3794
3795    #[test]
3796    fn advance_channel_epoch_archives_when_no_head_row() {
3797        // A rekey for a channel with no community_channels head row: archive the key, don't fabricate
3798        // a head. (Exercises advance_channel_epoch's channel-row-absent branch directly.)
3799        let (_tmp, _guard) = init_test_db();
3800        let cid = "f".repeat(64);
3801        let orphan_channel = "a".repeat(64);
3802        let advanced = crate::db::community::advance_channel_epoch(&cid, &orphan_channel, 2, &[0x77u8; 32]).unwrap();
3803        assert!(!advanced, "no head row → head not advanced");
3804        assert_eq!(crate::db::community::held_epoch_key(&cid, &orphan_channel, 2).unwrap(), Some([0x77u8; 32]), "key still archived");
3805    }
3806
3807    #[tokio::test]
3808    async fn rotate_channel_publishes_recoverable_rekey_and_advances_own_head() {
3809        use crate::community::derive::{recipient_pseudonym, rekey_pseudonym};
3810        use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
3811        let (_tmp, _guard) = init_test_db();
3812        let owner = Keys::generate();
3813        become_local(&owner); // I am the owner (supreme authority to rotate)
3814        let community = saved_community_owned_by(&owner);
3815        let channel_id = community.channels[0].id;
3816        let member = Keys::generate(); // a stayer who must recover the new key
3817        let relay = MemoryRelay::new();
3818
3819        let new_epoch = rotate_channel(&relay, &community, &channel_id, &[member.public_key()], community.server_root_key.as_bytes())
3820            .await
3821            .expect("rotate");
3822        assert_eq!(new_epoch, 1);
3823
3824        // My own head advanced to the new epoch + a fresh key.
3825        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3826        assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
3827
3828        // The published rekey is found at the SERVER-ROOT-derived address (no channel key needed) and
3829        // opens under the server root.
3830        let addr = rekey_pseudonym(
3831            &crate::community::ServerRootKey(*community.server_root_key.as_bytes()),
3832            &channel_id, crate::community::Epoch(1),
3833        )
3834        .to_hex();
3835        let found = relay
3836            .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
3837            .await
3838            .unwrap();
3839        assert_eq!(found.len(), 1, "rekey addressable by its server-root pseudonym");
3840        let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
3841        assert_eq!(parsed.rotator, owner.public_key());
3842        assert_eq!(parsed.new_epoch, crate::community::Epoch(1));
3843        assert_eq!(parsed.prev_epoch, crate::community::Epoch(0));
3844        assert_eq!(parsed.blobs.len(), 2, "the member + me (multi-device) each get a blob");
3845
3846        // The member recovers a key, and it is EXACTLY the key my head advanced to (one source of truth).
3847        let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
3848        let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3849        let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
3850        let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
3851        assert_eq!(reloaded.channels[0].key.as_bytes(), &recovered, "member's recovered key == my advanced head key");
3852    }
3853
3854    #[tokio::test]
3855    async fn rotate_channel_failed_publish_leaves_head_unadvanced() {
3856        // The publish-before-advance invariant: if the publish fails, my local head must NOT move to an
3857        // epoch no peer received (else I'd be stranded talking to no one).
3858        let (_tmp, _guard) = init_test_db();
3859        let owner = Keys::generate();
3860        become_local(&owner);
3861        let community = saved_community_owned_by(&owner);
3862        let member = Keys::generate();
3863        let err = rotate_channel(&FailingRelay, &community, &community.channels[0].id, &[member.public_key()], community.server_root_key.as_bytes()).await;
3864        assert!(err.is_err(), "a failed publish must propagate, not silently advance");
3865        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3866        assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0), "head stays put on publish failure");
3867    }
3868
3869    /// Build a properly-chained run of channel rekeys (epoch 1..=n), each citing the prior epoch's key
3870    /// commitment (epoch 1 cites the genesis key), each carrying a blob for `recipient_pk`. Returns the
3871    /// events + the per-epoch keys. Does NOT touch the DB (so the recipient stays "behind" at epoch 0).
3872    fn build_rekey_chain(
3873        owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::prelude::PublicKey, n: u64,
3874    ) -> (Vec<Event>, Vec<[u8; 32]>) {
3875        let chan = &community.channels[0];
3876        let scope = super::super::derive::RekeyScope::Channel(chan.id);
3877        let mut prev_key = *chan.key.as_bytes();
3878        let mut events = Vec::new();
3879        let mut keys = Vec::new();
3880        for e in 1..=n {
3881            let new_key = [e as u8; 32];
3882            let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient_pk, scope, crate::community::Epoch(e), &new_key).unwrap();
3883            let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prev_key);
3884            let ev = super::super::rekey::build_channel_rekey_event(
3885                &Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
3886                crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
3887            ).unwrap();
3888            events.push(ev);
3889            keys.push(new_key);
3890            prev_key = new_key;
3891        }
3892        (events, keys)
3893    }
3894
3895    #[tokio::test]
3896    async fn catch_up_steps_over_a_missing_epoch() {
3897        // W1: a relay-incomplete gap (epoch 2 absent). Catch-up applies 1, steps over the missing 2
3898        // (logged), applies 3 → head reaches the latest present epoch; epoch-2's key stays a hole.
3899        let (_tmp, _guard) = init_test_db();
3900        let owner = Keys::generate();
3901        let me = Keys::generate();
3902        become_local(&me);
3903        let community = saved_community_owned_by(&owner);
3904        let channel_id = community.channels[0].id;
3905        let cid = community.id.to_hex();
3906        let chan_hex = channel_id.to_hex();
3907
3908        let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
3909        let relay = MemoryRelay::new();
3910        relay.inject(&events[0], &community.relays); // epoch 1
3911        relay.inject(&events[2], &community.relays); // epoch 3 — epoch 2 deliberately omitted
3912        let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3913
3914        assert_eq!(reached, 3, "head reaches the latest present epoch, stepping over the gap");
3915        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(keys[0]));
3916        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), None, "missing epoch is a hole");
3917        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(keys[2]));
3918    }
3919
3920    #[tokio::test]
3921    async fn catch_up_recovers_a_rekey_under_a_prior_server_root() {
3922        // A channel rekey is addressed + encrypted under whatever server root was current at publish, and
3923        // the root ratchets on every base rotation. After the base rotates 0→1, an epoch-1 channel rekey
3924        // published under root-0 must STILL be found + opened (we hold root-0 in the archive) — else its
3925        // key is lost. Cross-root catch-up.
3926        let (_tmp, _guard) = init_test_db();
3927        let owner = Keys::generate();
3928        let me = Keys::generate();
3929        become_local(&me);
3930        let root0_community = saved_community_owned_by(&owner);
3931        let cid = root0_community.id.to_hex();
3932        let channel_id = root0_community.channels[0].id;
3933        let chan_hex = channel_id.to_hex();
3934        let scope = super::super::derive::RekeyScope::Channel(channel_id);
3935        let genesis_key = *root0_community.channels[0].key.as_bytes();
3936
3937        // Base rotation 0→1; the member now holds BOTH roots (epoch 0 from save, epoch 1 from advance).
3938        let root1 = [0x99u8; 32];
3939        crate::db::community::advance_server_root_epoch(&cid, 1, &root1).unwrap();
3940        let community = crate::db::community::load_community(&root0_community.id).unwrap().unwrap();
3941        assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
3942
3943        // Epoch-1 channel rekey under the PRIOR root (root-0); epoch-2 under the CURRENT root (root-1).
3944        let (k1, k2) = ([0x11u8; 32], [0x22u8; 32]);
3945        let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
3946        let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
3947        let ev1 = super::super::rekey::build_channel_rekey_event(
3948            &Keys::generate(), &owner, root0_community.server_root_key.as_bytes(), &channel_id,
3949            crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
3950        let blob2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &k2).unwrap();
3951        let commit1 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1);
3952        let ev2 = super::super::rekey::build_channel_rekey_event(
3953            &Keys::generate(), &owner, &root1, &channel_id,
3954            crate::community::Epoch(2), crate::community::Epoch(1), &commit1, &[blob2]).unwrap();
3955
3956        let relay = MemoryRelay::new();
3957        relay.inject(&ev1, &community.relays);
3958        relay.inject(&ev2, &community.relays);
3959
3960        let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3961        assert_eq!(reached, 2, "reached the latest channel epoch across the server-root rotation");
3962        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
3963            "epoch-1 key recovered from a rekey under the PRIOR server root");
3964        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(k2));
3965    }
3966
3967    #[tokio::test]
3968    async fn catch_up_backfills_a_sub_head_gap() {
3969        // An EXISTING hole below the head (an earlier catch-up leapfrogged epoch 1). The forward window
3970        // never revisits sub-head epochs, so the backward gap-fill must re-fetch + apply it.
3971        let (_tmp, _guard) = init_test_db();
3972        let owner = Keys::generate();
3973        let me = Keys::generate();
3974        become_local(&me);
3975        let community = saved_community_owned_by(&owner);
3976        let cid = community.id.to_hex();
3977        let channel_id = community.channels[0].id;
3978        let chan_hex = channel_id.to_hex();
3979        let scope = super::super::derive::RekeyScope::Channel(channel_id);
3980        let genesis_key = *community.channels[0].key.as_bytes();
3981
3982        // Pre-existing state: head already at epoch 2 (with its key), but epoch 1 is a HOLE.
3983        let k2 = [0x22u8; 32];
3984        crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &k2).unwrap();
3985        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), None, "epoch 1 starts as a hole");
3986
3987        // Epoch-1's rekey is on relays (under the current root). The backward gap-fill should recover it.
3988        let k1 = [0x11u8; 32];
3989        let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
3990        let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
3991        let ev1 = super::super::rekey::build_channel_rekey_event(
3992            &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
3993            crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
3994        let relay = MemoryRelay::new();
3995        relay.inject(&ev1, &community.relays);
3996
3997        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
3998        let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3999        assert_eq!(reached, 2, "head unchanged (gap-fill never regresses it)");
4000        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
4001            "the sub-head hole was backfilled");
4002    }
4003
4004    #[tokio::test]
4005    async fn catch_up_walks_a_chain_of_rotations_to_the_latest() {
4006        let (_tmp, _guard) = init_test_db();
4007        let owner = Keys::generate();
4008        let me = Keys::generate();
4009        become_local(&me); // I'm a member, behind at epoch 0
4010        let community = saved_community_owned_by(&owner);
4011        let channel_id = community.channels[0].id;
4012        let cid = community.id.to_hex();
4013        let chan_hex = channel_id.to_hex();
4014
4015        // 3 rotations happened while I was away; inject them onto the relay (unordered).
4016        let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
4017        let relay = MemoryRelay::new();
4018        for ev in events.iter().rev() {
4019            relay.inject(ev, &community.relays);
4020        }
4021
4022        let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4023        assert_eq!(reached, 3, "caught up to the latest epoch");
4024        // Head advanced to 3 with epoch-3's key; ALL intervening epoch keys retained.
4025        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4026        assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(3));
4027        assert_eq!(reloaded.channels[0].key.as_bytes(), &keys[2]);
4028        for (i, k) in keys.iter().enumerate() {
4029            assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, (i + 1) as u64).unwrap(), Some(*k));
4030        }
4031    }
4032
4033    #[tokio::test]
4034    async fn catch_up_slides_across_the_window_boundary() {
4035        // Exercises the multi-round slide arithmetic: 70 contiguous rotations (all for me) exceed the
4036        // 64-wide window, so catch-up must fetch window 1 (1..64), advance, then slide to window 2 and
4037        // reach 70 — proving the window math, not just a single-window apply.
4038        let (_tmp, _guard) = init_test_db();
4039        let owner = Keys::generate();
4040        let me = Keys::generate();
4041        become_local(&me);
4042        let community = saved_community_owned_by(&owner);
4043        let channel_id = community.channels[0].id;
4044        let cid = community.id.to_hex();
4045        let chan_hex = channel_id.to_hex();
4046
4047        let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 70);
4048        let relay = MemoryRelay::new();
4049        for ev in &events {
4050            relay.inject(ev, &community.relays);
4051        }
4052        let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4053        assert_eq!(reached, 70, "slid past the 64-epoch window boundary to the latest");
4054        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 70).unwrap(), Some(keys[69]));
4055        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 64).unwrap(), Some(keys[63]), "window-1 keys retained too");
4056    }
4057
4058    // --- catch_up_server_root (#4d) ---
4059
4060    /// A properly-chained run of base rekeys (epoch 1..=n), each enveloped under the PRIOR root and
4061    /// citing it, each carrying a ServerRoot blob for `recipient_pk`. Returns the events + per-epoch
4062    /// roots. Does NOT touch the DB (the recipient stays "behind" at base epoch 0).
4063    fn build_base_rekey_chain(
4064        owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::prelude::PublicKey, n: u64,
4065    ) -> (Vec<Event>, Vec<[u8; 32]>) {
4066        let mut prior_root = *community.server_root_key.as_bytes();
4067        let mut events = Vec::new();
4068        let mut roots = Vec::new();
4069        for e in 1..=n {
4070            let new_root = [(e % 256) as u8; 32];
4071            let blob = super::super::rekey::build_rekey_blob(
4072                owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(e), &new_root,
4073            )
4074            .unwrap();
4075            let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prior_root);
4076            events.push(super::super::rekey::build_server_root_rekey_event(
4077                &Keys::generate(), owner, &prior_root, &community.id,
4078                crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
4079            ).unwrap());
4080            roots.push(new_root);
4081            prior_root = new_root;
4082        }
4083        (events, roots)
4084    }
4085
4086    #[tokio::test]
4087    async fn catch_up_server_root_walks_a_chain_of_base_rotations() {
4088        let (_tmp, _guard) = init_test_db();
4089        let owner = Keys::generate();
4090        let me = Keys::generate();
4091        become_local(&me);
4092        let community = saved_community_owned_by(&owner);
4093        let cid = community.id.to_hex();
4094
4095        let (events, roots) = build_base_rekey_chain(&owner, &community, &me.public_key(), 3);
4096        let relay = MemoryRelay::new();
4097        for ev in events.iter().rev() {
4098            relay.inject(ev, &community.relays);
4099        }
4100        let reached = catch_up_server_root(&relay, &community).await.unwrap();
4101        assert_eq!(reached.epoch, 3, "walked the base chain to the latest epoch");
4102        assert!(!reached.removed, "a normal catch-up is not a removal");
4103        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4104        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(3));
4105        assert_eq!(reloaded.server_root_key.as_bytes(), &roots[2], "base head is the latest root");
4106        // All intervening roots retained (read old control/base history).
4107        for (i, r) in roots.iter().enumerate() {
4108            assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, (i + 1) as u64).unwrap(), Some(*r));
4109        }
4110    }
4111
4112    #[tokio::test]
4113    async fn catch_up_recovers_from_a_split_base_rotation_second_chunk() {
4114        // SPLIT: a base rotation at epoch 1 is published as TWO chunk events at the SAME address; MY
4115        // blob is in the SECOND chunk. The walk must try both and recover from chunk 2 — the old
4116        // first-match logic would have hit chunk 1 (no blob for me), read it as removal, and stranded me.
4117        let (_tmp, _guard) = init_test_db();
4118        let owner = Keys::generate();
4119        let me = Keys::generate();
4120        become_local(&me);
4121        let community = saved_community_owned_by(&owner);
4122        let genesis = *community.server_root_key.as_bytes();
4123        let new_root = [0x5Au8; 32];
4124        let scope = super::super::derive::RekeyScope::ServerRoot;
4125        let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
4126        let mk = |recipient: &nostr_sdk::prelude::PublicKey| {
4127            let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient, scope, crate::community::Epoch(1), &new_root).unwrap();
4128            super::super::rekey::build_server_root_rekey_event(
4129                &Keys::generate(), &owner, &genesis, &community.id,
4130                crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
4131            ).unwrap()
4132        };
4133        let relay = MemoryRelay::new();
4134        relay.inject(&mk(&Keys::generate().public_key()), &community.relays); // chunk 1: NOT for me
4135        relay.inject(&mk(&me.public_key()), &community.relays); // chunk 2: my blob
4136
4137        let reached = catch_up_server_root(&relay, &community).await.unwrap();
4138        assert_eq!(reached.epoch, 1, "recovered the split rotation via the second chunk");
4139        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4140        assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "recovered the new root from chunk 2");
4141    }
4142
4143    #[tokio::test]
4144    async fn catch_up_converges_concurrent_refoundings_on_the_lowest_root() {
4145        // B2: two BAN-holders re-found at the SAME epoch, each delivering a DIFFERENT new root to me. Every
4146        // member must pick the SAME canonical root or the community forks irrecoverably. The walk converges
4147        // on the LOWEST new-root bytes — deterministic for everyone — regardless of which arrived first.
4148        let (_tmp, _guard) = init_test_db();
4149        let owner = Keys::generate();
4150        let me = Keys::generate();
4151        become_local(&me);
4152        let community = saved_community_owned_by(&owner);
4153        let genesis = *community.server_root_key.as_bytes();
4154        let scope = super::super::derive::RekeyScope::ServerRoot;
4155        let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
4156        let root_lo = [0x10u8; 32];
4157        let root_hi = [0xF0u8; 32]; // root_lo < root_hi bytewise
4158        let mk = |root: &[u8; 32]| {
4159            let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), root).unwrap();
4160            super::super::rekey::build_server_root_rekey_event(
4161                &Keys::generate(), &owner, &genesis, &community.id,
4162                crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
4163            ).unwrap()
4164        };
4165        let relay = MemoryRelay::new();
4166        // Inject the HIGHER root FIRST — "first-arrived" logic would pick the wrong one without the tiebreak.
4167        relay.inject(&mk(&root_hi), &community.relays);
4168        relay.inject(&mk(&root_lo), &community.relays);
4169
4170        let reached = catch_up_server_root(&relay, &community).await.unwrap();
4171        assert_eq!(reached.epoch, 1, "advanced one epoch");
4172        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4173        assert_eq!(reloaded.server_root_key.as_bytes(), &root_lo, "converged on the LOWEST root, not the first-arrived");
4174    }
4175
4176    #[tokio::test]
4177    async fn rotate_retry_reuses_the_archived_root_no_same_epoch_fork() {
4178        // FORK-SAFETY crux: a rotation whose publish fails archives the new root, and a RETRY reuses that
4179        // SAME root (never mints a fresh one for the same epoch — which would split recipients onto
4180        // incompatible keys). Fail the base rekey publish, capture the archived root, recover the relay,
4181        // retry, and assert the root is identical.
4182        let (_tmp, _guard) = init_test_db();
4183        let owner = Keys::generate();
4184        become_local(&owner);
4185        let community = saved_community_owned_by(&owner);
4186        let cid = community.id.to_hex();
4187        let relay = RekeyFailingRelay::new(); // base rekey (3303) publish fails
4188        let member = Keys::generate();
4189
4190        assert!(rotate_server_root(&relay, &community, &[member.public_key()]).await.is_err(), "the rekey publish fails");
4191        let k1 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap()
4192            .expect("the new root is archived before publishing (fork-safety)");
4193        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4194        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "head not advanced on a failed publish");
4195
4196        relay.allow_rekey();
4197        rotate_server_root(&relay, &reloaded, &[member.public_key()]).await.unwrap();
4198        let k2 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap().unwrap();
4199        assert_eq!(k1, k2, "the retry REUSES the archived root — no second root for epoch 1, no fork");
4200        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4201        assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "the retry completed the rotation");
4202        assert_eq!(after.server_root_key.as_bytes(), &k1, "the committed root is the one minted on attempt 1");
4203    }
4204
4205    #[tokio::test]
4206    async fn rotate_server_root_splits_a_large_recipient_set_into_multiple_events() {
4207        // A recipient set past MAX_REKEY_BLOBS publishes as MULTIPLE chunk events at one address.
4208        let (_tmp, _guard) = init_test_db();
4209        let owner = Keys::generate();
4210        become_local(&owner);
4211        let community = saved_community_owned_by(&owner);
4212        let genesis = *community.server_root_key.as_bytes();
4213        let relay = MemoryRelay::new();
4214        // MAX_REKEY_BLOBS recipients + the owner self-blob = MAX+1 blobs → exactly 2 chunks.
4215        let recipients: Vec<_> = (0..super::super::rekey::MAX_REKEY_BLOBS).map(|_| Keys::generate().public_key()).collect();
4216        rotate_server_root(&relay, &community, &recipients).await.unwrap();
4217        let addr = super::super::derive::base_rekey_pseudonym(&super::super::ServerRootKey(genesis), &community.id, crate::community::Epoch(1)).to_hex();
4218        let evs = relay
4219            .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4220            .await
4221            .unwrap();
4222        assert_eq!(evs.len(), 2, "a >MAX_REKEY_BLOBS rotation splits into 2 events at one address");
4223    }
4224
4225    #[tokio::test]
4226    async fn catch_up_server_root_is_a_noop_with_no_rotations() {
4227        let (_tmp, _guard) = init_test_db();
4228        let owner = Keys::generate();
4229        let me = Keys::generate();
4230        become_local(&me);
4231        let community = saved_community_owned_by(&owner);
4232        let relay = MemoryRelay::new();
4233        assert_eq!(catch_up_server_root(&relay, &community).await.unwrap().epoch, 0, "no base rotations → stays at 0");
4234    }
4235
4236    #[tokio::test]
4237    async fn concurrent_refounders_converge_to_the_lowest_root() {
4238        // Two BAN-holders re-found at the SAME epoch with DIFFERENT roots → each ORIGINATOR ends on its own
4239        // root (the forward walk only tiebreaks at head+1). The current-head convergence reconciles them:
4240        // whoever holds the HIGHER root adopts the LOWER (deterministic winner). This is the exact case the
4241        // live dual-admin race broke — the bystander-only B2 test never covered the originators self-healing.
4242        let (_tmp, _guard) = init_test_db();
4243        let owner = Keys::generate();
4244        let me = Keys::generate();
4245        become_local(&me); // a member sitting on the LOSING (higher) root after my own concurrent re-founding
4246        let community = saved_community_owned_by(&owner);
4247        let cid = community.id.to_hex();
4248        let genesis_root = *community.server_root_key.as_bytes();
4249        let scope = super::super::derive::RekeyScope::ServerRoot;
4250
4251        // The OTHER originator's epoch-1 base rekey (root_lo, the winner) — carries a blob for ME, addressed
4252        // under the genesis (prior) root. Owner-authored, so it's authorized (supreme) regardless of roster.
4253        let root_lo = [0x10u8; 32];
4254        let root_hi = [0x99u8; 32]; // my own losing fork's root
4255        let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4256        let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_lo).unwrap();
4257        let ev_lo = super::super::rekey::build_server_root_rekey_event(
4258            &Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4259
4260        let relay = MemoryRelay::new();
4261        relay.inject(&ev_lo, &community.relays);
4262
4263        // I'm currently on the HIGHER root at epoch 1 (my own losing fork).
4264        crate::db::community::advance_server_root_epoch(&cid, 1, &root_hi).unwrap();
4265        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4266        assert_eq!(community.server_root_key.as_bytes(), &root_hi, "start on the higher root");
4267
4268        let out = catch_up_server_root(&relay, &community).await.unwrap();
4269        assert_eq!(out.epoch, 1, "converged in place at the same epoch (not advanced)");
4270        assert!(!out.removed);
4271        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4272        assert_eq!(after.server_root_key.as_bytes(), &root_lo, "originator converged to the lowest authorized root");
4273
4274        // Idempotent: a second pass holding the winner stays put (no flip back to the higher root).
4275        let _ = catch_up_server_root(&relay, &after).await.unwrap();
4276        assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_key.as_bytes(), &root_lo, "no flip-flop");
4277    }
4278
4279    #[tokio::test]
4280    async fn banned_rotators_rekey_is_not_a_convergence_candidate() {
4281        // §6 banlist precedence on the rekey plane: an admin who holds a (withheld-revoke) BAN grant
4282        // but sits on the SYNCED banlist must not be honored as a rotator — not by apply, not by the
4283        // forward walk, not by the heal. Here the banned admin's re-founding delivers a byte-LOWER
4284        // root than the one I hold; without the banlist gate the heal would adopt it.
4285        let (_tmp, _guard) = init_test_db();
4286        let owner = Keys::generate();
4287        let me = Keys::generate();
4288        let banned_admin = Keys::generate();
4289        become_local(&me);
4290        let community = saved_community_owned_by(&owner);
4291        let cid = community.id.to_hex();
4292        let genesis_root = *community.server_root_key.as_bytes();
4293        let scope = super::super::derive::RekeyScope::ServerRoot;
4294
4295        // The attacker still ranks in the roster (their grant-revoke is "withheld")...
4296        let role_id = "e".repeat(64);
4297        let roster = crate::community::roles::CommunityRoles {
4298            roles: vec![crate::community::roles::Role::admin(role_id.clone())],
4299            grants: vec![crate::community::roles::MemberGrant { member: banned_admin.public_key().to_hex(), role_ids: vec![role_id] }],
4300        };
4301        crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
4302        // ...but the banlist naming them DID sync. Banlist must dominate.
4303        crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
4304
4305        // Banned admin's epoch-1 re-founding with a ground-low root, blob addressed to me.
4306        let root_evil = [0x01u8; 32];
4307        let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4308        let blob = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
4309        let ev = super::super::rekey::build_server_root_rekey_event(
4310            &Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob]).unwrap();
4311        let relay = MemoryRelay::new();
4312        relay.inject(&ev, &community.relays);
4313
4314        // Forward walk: the banned rotation is the ONLY epoch-1 candidate → not adopted, not a
4315        // removal signal (a banned admin can't trick members into self-erasing either).
4316        let out = catch_up_server_root(&relay, &community).await.unwrap();
4317        assert_eq!(out.epoch, 0, "banned rotator's re-founding must not advance the base");
4318        assert!(!out.removed, "banned rotator's exclusion must not read as an authorized removal");
4319        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4320        assert_eq!(after.server_root_key.as_bytes(), &genesis_root, "root unchanged");
4321
4322        // Direct apply refuses too.
4323        let parsed = super::super::rekey::open_rekey_event(&ev, &genesis_root).unwrap();
4324        assert!(apply_server_root_rekey(&community, &parsed).is_err(), "apply must refuse a banned rotator");
4325    }
4326
4327    #[tokio::test]
4328    async fn heal_abandons_a_deauthorized_root_for_the_authorized_higher_sibling() {
4329        // B1 (rekey-race fork): I adopted a since-BANNED admin's ground-low epoch-1 root before the
4330        // banlist reached me. Once the banlist syncs, the heal must abandon their root and climb UP
4331        // to the owner's legitimate (byte-higher) sibling — authority dominates the down-only rule.
4332        let (_tmp, _guard) = init_test_db();
4333        let owner = Keys::generate();
4334        let me = Keys::generate();
4335        let banned_admin = Keys::generate();
4336        become_local(&me);
4337        let community = saved_community_owned_by(&owner);
4338        let cid = community.id.to_hex();
4339        let genesis_root = *community.server_root_key.as_bytes();
4340        let scope = super::super::derive::RekeyScope::ServerRoot;
4341        let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4342
4343        // Both epoch-1 siblings on the wire, addressed under the shared genesis root:
4344        // the attacker's (ground-low) and the owner's (higher).
4345        let root_evil = [0x01u8; 32];
4346        let root_owner = [0x77u8; 32];
4347        let blob_evil = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
4348        let ev_evil = super::super::rekey::build_server_root_rekey_event(
4349            &Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_evil]).unwrap();
4350        let blob_owner = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_owner).unwrap();
4351        let ev_owner = super::super::rekey::build_server_root_rekey_event(
4352            &Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_owner]).unwrap();
4353        let relay = MemoryRelay::new();
4354        relay.inject(&ev_evil, &community.relays);
4355        relay.inject(&ev_owner, &community.relays);
4356
4357        // I already adopted the attacker's root at epoch 1 (the race), and the ban has now synced.
4358        crate::db::community::advance_server_root_epoch(&cid, 1, &root_evil).unwrap();
4359        crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
4360        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4361        assert_eq!(community.server_root_key.as_bytes(), &root_evil, "start partitioned on the attacker's root");
4362
4363        let out = catch_up_server_root(&relay, &community).await.unwrap();
4364        assert_eq!(out.epoch, 1);
4365        assert!(!out.removed);
4366        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4367        assert_eq!(after.server_root_key.as_bytes(), &root_owner,
4368            "heal must abandon the deauthorized root and adopt the owner's higher sibling");
4369
4370        // Stable: re-running keeps the owner's root (the attacker's lower root never wins again).
4371        let _ = catch_up_server_root(&relay, &after).await.unwrap();
4372        assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_key.as_bytes(), &root_owner, "no flap back to the banned root");
4373    }
4374
4375    #[tokio::test]
4376    async fn concurrent_channel_rekeyers_converge_to_the_lowest_key() {
4377        // Two MANAGE_CHANNELS holders rotate the SAME channel at the SAME epoch with DIFFERENT keys —
4378        // a true fork inside the propagation window. Both rekeys land at the same address under the (already
4379        // converged) server root, so relay order would otherwise decide last-write-wins. The current-head
4380        // heal must pick the LOWEST delivered key deterministically — every member computes the same winner.
4381        let (_tmp, _guard) = init_test_db();
4382        let owner = Keys::generate();
4383        let me = Keys::generate();
4384        become_local(&me); // a member sitting on the LOSING (higher) channel key after my own fork
4385        let community = saved_community_owned_by(&owner);
4386        let cid = community.id.to_hex();
4387        let channel_id = community.channels[0].id;
4388        let chan_hex = channel_id.to_hex();
4389        let scope = super::super::derive::RekeyScope::Channel(channel_id);
4390        let genesis_key = *community.channels[0].key.as_bytes();
4391        let root = *community.server_root_key.as_bytes();
4392        let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4393
4394        // Two owner-authorized epoch-1 channel rekeys, each carrying a blob for ME, both citing genesis.
4395        let key_lo = [0x10u8; 32];
4396        let key_hi = [0x99u8; 32];
4397        let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4398        let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4399        let ev_lo = super::super::rekey::build_channel_rekey_event(
4400            &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4401        let ev_hi = super::super::rekey::build_channel_rekey_event(
4402            &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4403
4404        let relay = MemoryRelay::new();
4405        relay.inject(&ev_hi, &community.relays); // inject the HIGHER first: naive relay-order would pick it
4406        relay.inject(&ev_lo, &community.relays);
4407
4408        // The two forked channel rekeys are addressed under the PRIOR
4409        // (shared) root they cite, not the current one. Advance the SERVER root so genesis becomes a prior
4410        // root — the heal must search EVERY held root to find them. A current-root-only fetch
4411        // missed both and never converged (the channel forked live while the base healed).
4412        crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4413        // I'm currently on the HIGHER key at epoch 1 (my own losing fork).
4414        crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4415        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4416
4417        let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4418        assert_eq!(reached, 1, "converged in place at the same channel epoch");
4419        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
4420            "adopted the lowest delivered key regardless of relay order");
4421
4422        // Idempotent: re-running holding the winner stays put (no flip back to the higher key).
4423        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4424        let _ = catch_up_channel_rekeys(&relay, &after, &channel_id).await.unwrap();
4425        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo), "no flip-flop");
4426    }
4427
4428    #[tokio::test]
4429    async fn concurrent_channel_rekeyers_converge_when_i_authored_the_losing_fork() {
4430        // FAITHFUL LIVE REPLICA of the dual-admin ban (the case the simpler test missed): TWO DISTINCT
4431        // authorized rotators (owner + a granted admin), and the LOCAL user IS one of them — I authored the
4432        // HIGHER (losing) channel rekey myself, the owner authored the lower. Both sit under the PRIOR shared
4433        // root, both deliver a blob to me. The heal must still converge ME down to the owner's lower key.
4434        let (_tmp, _guard) = init_test_db();
4435        let owner = Keys::generate();
4436        let me = Keys::generate(); // I am the ADMIN rotator (not a bystander) — mirrors the agent in the live test
4437        become_local(&me);
4438        let community = saved_community_owned_by(&owner);
4439        let cid = community.id.to_hex();
4440        let channel_id = community.channels[0].id;
4441        let chan_hex = channel_id.to_hex();
4442        let scope = super::super::derive::RekeyScope::Channel(channel_id);
4443        let genesis_key = *community.channels[0].key.as_bytes();
4444        let root = *community.server_root_key.as_bytes();
4445        let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4446
4447        // Grant ME (the admin) a role carrying MANAGE_CHANNELS, so MY OWN rekey is an authorized candidate
4448        // (owner is supreme regardless). Without this the heal would trivially pick the owner's; with it,
4449        // BOTH siblings are authorized — exactly the live ambiguity that must resolve to the lowest key.
4450        let role_id = "d".repeat(64);
4451        let roster = crate::community::roles::CommunityRoles {
4452            roles: vec![crate::community::roles::Role::admin(role_id.clone())],
4453            grants: vec![crate::community::roles::MemberGrant { member: me.public_key().to_hex(), role_ids: vec![role_id] }],
4454        };
4455        crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
4456
4457        let key_lo = [0x10u8; 32]; // owner's (the winner)
4458        let key_hi = [0x99u8; 32]; // MINE (the losing fork I authored + currently hold)
4459        // Owner's rekey: rotator = owner, blob for ME.
4460        let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4461        let ev_lo = super::super::rekey::build_channel_rekey_event(
4462            &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4463        // MY rekey: rotator = me (the admin), blob for ME (self-delivered, as rotate_channel always adds self).
4464        let blob_hi = super::super::rekey::build_rekey_blob(me.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4465        let ev_hi = super::super::rekey::build_channel_rekey_event(
4466            &Keys::generate(), &me, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4467
4468        let relay = MemoryRelay::new();
4469        relay.inject(&ev_hi, &community.relays);
4470        relay.inject(&ev_lo, &community.relays);
4471
4472        // The rekeys are under genesis (prior) root; advance the SERVER root so genesis is no longer current.
4473        crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4474        // I currently hold MY OWN (higher) key at channel epoch 1.
4475        crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4476        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4477
4478        let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4479        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
4480            "I authored the losing fork but must converge DOWN to the owner's lower key");
4481    }
4482
4483    #[tokio::test]
4484    async fn reorg_through_a_fork_heals_the_forked_past_epoch() {
4485        // I sit on the LOSING sibling at a PAST channel epoch (epoch 1) and then reorg forward when an
4486        // authorized epoch-2 rekey continues from the WINNING epoch-1 key. Advancing the head alone leaves
4487        // epoch 1 on the wrong key (its messages unreadable). catch_up must re-converge the forked PAST epoch
4488        // to the lowest sibling — not just the head.
4489        let (_tmp, _guard) = init_test_db();
4490        let owner = Keys::generate();
4491        let me = Keys::generate();
4492        become_local(&me);
4493        let community = saved_community_owned_by(&owner);
4494        let cid = community.id.to_hex();
4495        let channel_id = community.channels[0].id;
4496        let chan_hex = channel_id.to_hex();
4497        let scope = super::super::derive::RekeyScope::Channel(channel_id);
4498        let genesis_key = *community.channels[0].key.as_bytes();
4499        let root = *community.server_root_key.as_bytes();
4500        let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4501
4502        // Two owner-authorized epoch-1 siblings (the fork), both delivering a blob to me.
4503        let key_lo1 = [0x10u8; 32]; // winner at epoch 1
4504        let key_hi1 = [0x99u8; 32]; // loser at epoch 1 (what I currently hold)
4505        let key_e2 = [0x20u8; 32]; // epoch 2, continuing from the WINNER's key_lo1
4506        let blob_lo1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo1).unwrap();
4507        let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
4508        let ev_lo1 = super::super::rekey::build_channel_rekey_event(
4509            &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
4510        let ev_hi1 = super::super::rekey::build_channel_rekey_event(
4511            &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
4512        // Epoch 2 cites the WINNER's epoch-1 key — applying it while I hold key_hi1 is the reorg.
4513        let commit1_win = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &key_lo1);
4514        let blob_e2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &key_e2).unwrap();
4515        let ev_e2 = super::super::rekey::build_channel_rekey_event(
4516            &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit1_win, &[blob_e2]).unwrap();
4517
4518        let relay = MemoryRelay::new();
4519        relay.inject(&ev_lo1, &community.relays);
4520        relay.inject(&ev_hi1, &community.relays);
4521        relay.inject(&ev_e2, &community.relays);
4522
4523        // All three rekeys are under the genesis (now-prior) server root.
4524        crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4525        // I'm sitting on the LOSING epoch-1 key.
4526        crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
4527        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4528
4529        let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4530        assert_eq!(reached, 2, "reorged forward to the head epoch");
4531        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch adopted");
4532        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
4533            "the FORKED past epoch re-converged to the lowest sibling (its messages become readable)");
4534    }
4535
4536    #[tokio::test]
4537    async fn window_heal_converges_an_already_reorged_past_fork() {
4538        // A member sitting at head epoch 2 holding the LOSING sibling at epoch 1, with NO new rekey to apply
4539        // this sync (so the in-sync forked-epoch set stays empty). The recent-window heal must STILL
4540        // re-converge epoch 1 to the lowest sibling — otherwise its messages are stranded forever. Distinct
4541        // from `reorg_through_a_fork_*` (which reorgs in-sync).
4542        let (_tmp, _guard) = init_test_db();
4543        let owner = Keys::generate();
4544        let me = Keys::generate();
4545        become_local(&me);
4546        let community = saved_community_owned_by(&owner);
4547        let cid = community.id.to_hex();
4548        let channel_id = community.channels[0].id;
4549        let chan_hex = channel_id.to_hex();
4550        let scope = super::super::derive::RekeyScope::Channel(channel_id);
4551        let genesis_key = *community.channels[0].key.as_bytes();
4552        let root = *community.server_root_key.as_bytes();
4553        let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4554
4555        let key_lo1 = [0x10u8; 32]; // winner at epoch 1 (on the wire, authorized, blob for me)
4556        let key_hi1 = [0x99u8; 32]; // loser at epoch 1 (what I currently hold)
4557        let key_e2 = [0x20u8; 32]; // my head at epoch 2 (already reorged here under the old build)
4558        let blob_lo1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo1).unwrap();
4559        let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
4560        let ev_lo1 = super::super::rekey::build_channel_rekey_event(
4561            &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
4562        let ev_hi1 = super::super::rekey::build_channel_rekey_event(
4563            &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
4564
4565        let relay = MemoryRelay::new();
4566        relay.inject(&ev_lo1, &community.relays);
4567        relay.inject(&ev_hi1, &community.relays);
4568        // NOTE: no epoch-2 rekey on the relay — nothing for the forward walk to apply, so the heal is the
4569        // ONLY thing that can fix epoch 1.
4570
4571        crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4572        // Simulate the prior-build reorg: I hold the LOSING epoch-1 key and have already advanced to epoch 2.
4573        crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
4574        crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &key_e2).unwrap();
4575        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4576
4577        let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4578        assert_eq!(reached, 2, "head unchanged (no new rekey to apply)");
4579        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch untouched");
4580        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
4581            "the already-forked past epoch re-converged to the lowest sibling via the window heal (no in-sync reorg)");
4582    }
4583
4584    #[tokio::test]
4585    async fn channel_heal_cannot_converge_to_a_key_i_was_not_given() {
4586        // The winning (lower) fork's channel rekey carries NO blob for me
4587        // (the other re-founder's retain set excluded me — e.g. it kept the just-banned victim and dropped
4588        // me in the concurrent-ban window). I literally cannot DECRYPT that key, so the heal can't adopt it
4589        // and I stay stranded on my own higher key. This proves the live bug is RETAIN-SET incompleteness in
4590        // concurrent re-founding, NOT the heal logic (which the two tests above prove correct). The fix must
4591        // guarantee each re-founder's rekey reaches the OTHER re-founder.
4592        let (_tmp, _guard) = init_test_db();
4593        let owner = Keys::generate();
4594        let me = Keys::generate();
4595        become_local(&me);
4596        let community = saved_community_owned_by(&owner);
4597        let cid = community.id.to_hex();
4598        let channel_id = community.channels[0].id;
4599        let chan_hex = channel_id.to_hex();
4600        let scope = super::super::derive::RekeyScope::Channel(channel_id);
4601        let genesis_key = *community.channels[0].key.as_bytes();
4602        let root = *community.server_root_key.as_bytes();
4603        let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4604
4605        let key_lo = [0x10u8; 32]; // owner's (lower) — but its rekey DOES NOT include me
4606        let key_hi = [0x99u8; 32]; // mine (higher) — the one I currently hold
4607        // Owner's lower rekey delivers ONLY to a third party (the banned victim's seat), NOT to me.
4608        let other = Keys::generate();
4609        let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4610        let ev_lo = super::super::rekey::build_channel_rekey_event(
4611            &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4612        // My higher rekey delivers to me.
4613        let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4614        let ev_hi = super::super::rekey::build_channel_rekey_event(
4615            &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4616
4617        let relay = MemoryRelay::new();
4618        relay.inject(&ev_lo, &community.relays);
4619        relay.inject(&ev_hi, &community.relays);
4620        crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4621        crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4622        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4623
4624        let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4625        // Excluded from the winning rekey: I can't decrypt the lower key, so I keep my own and cannot converge.
4626        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_hi),
4627            "excluded from the winning rekey ⇒ cannot converge");
4628    }
4629
4630    #[tokio::test]
4631    async fn refounding_channel_rekey_is_sealed_under_the_prior_root() {
4632        // #262 fix: a channel rekey accompanying a re-founding must be ENVELOPED + ADDRESSED under the PRIOR
4633        // (shared) root, NOT the re-founder's new one — so a base-fork loser (who dropped its own new root)
4634        // can still open it. This pins the write side: rotate_channel seals under the passed envelope_root,
4635        // and the event opens under that root and NOT under the community's current/new root.
4636        let (_tmp, _guard) = init_test_db();
4637        let owner = Keys::generate();
4638        become_local(&owner); // owner is supreme → authorized to rotate
4639        let community = saved_community_owned_by(&owner);
4640        let channel_id = community.channels[0].id;
4641        let prior_root = [0x11u8; 32]; // the shared pre-rotation root (≠ the community's current root)
4642
4643        let relay = MemoryRelay::new();
4644        rotate_channel(&relay, &community, &channel_id, &[owner.public_key()], &prior_root).await.unwrap();
4645
4646        // Addressed at the PRIOR-root pseudonym...
4647        let z = super::super::derive::rekey_pseudonym(&crate::community::ServerRootKey(prior_root), &channel_id, crate::community::Epoch(1)).to_hex();
4648        let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![z], ..Default::default() };
4649        let evs = relay.fetch(&q, &community.relays).await.unwrap();
4650        assert_eq!(evs.len(), 1, "channel rekey is addressed at the PRIOR-root pseudonym");
4651        // ...and opens ONLY under the prior root, NOT the community's current (new) root.
4652        assert!(super::super::rekey::open_rekey_event(&evs[0], &prior_root).is_ok(),
4653            "opens under the prior (shared) root every retained member still holds");
4654        assert!(super::super::rekey::open_rekey_event(&evs[0], community.server_root_key.as_bytes()).is_err(),
4655            "does NOT open under the current/new root (which a base-fork loser would have dropped)");
4656    }
4657
4658    #[tokio::test]
4659    async fn apply_channel_rekey_converges_past_a_divergent_prior_epoch() {
4660        // FORK-CONVERGENCE: I hold epoch-1 = my LOSING fork key. An AUTHORIZED rekey
4661        // to epoch 2 cites a DIFFERENT epoch-1 key (the winner's, which I never held) and delivers epoch-2 to
4662        // ME. The relaxed continuity check must ADOPT it (converge forward onto the authorized chain), not
4663        // reject it as a "foreign chain" and strand me on the dead fork forever.
4664        let (_tmp, _guard) = init_test_db();
4665        let owner = Keys::generate();
4666        let me = Keys::generate();
4667        become_local(&me);
4668        let community = saved_community_owned_by(&owner);
4669        let cid = community.id.to_hex();
4670        let channel_id = community.channels[0].id;
4671        let chan_hex = channel_id.to_hex();
4672        let scope = super::super::derive::RekeyScope::Channel(channel_id);
4673        let root = *community.server_root_key.as_bytes();
4674
4675        // I'm on my LOSING fork at epoch 1.
4676        let my_fork_key = [0xAAu8; 32];
4677        crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &my_fork_key).unwrap();
4678
4679        // Owner's epoch-2 rekey continues from the WINNER's epoch-1 (a key I never held) + delivers to me.
4680        let winner_epoch1 = [0xBBu8; 32];
4681        let new_key = [0x22u8; 32];
4682        let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &winner_epoch1);
4683        let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &new_key).unwrap();
4684        let ev = super::super::rekey::build_channel_rekey_event(
4685            &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit, &[blob]).unwrap();
4686        let parsed = super::super::rekey::open_rekey_event(&ev, &root).unwrap();
4687
4688        let outcome = apply_channel_rekey(&community, &parsed).unwrap();
4689        assert!(matches!(outcome, RekeyOutcome::Applied { head_advanced: true }),
4690            "must converge forward past the divergent prior epoch, got {outcome:?}");
4691        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(new_key),
4692            "adopted the winner's epoch-2 key");
4693    }
4694
4695    #[tokio::test]
4696    async fn catch_up_server_root_stops_when_removed_from_base() {
4697        // Recipient of base epoch 1 but NOT epoch 2 (removed from the base). The walk applies 1, opens
4698        // the epoch-2 envelope (I hold root_1) but finds no blob → NotARecipient → stops at 1.
4699        let (_tmp, _guard) = init_test_db();
4700        let owner = Keys::generate();
4701        let me = Keys::generate();
4702        become_local(&me);
4703        let community = saved_community_owned_by(&owner);
4704        let scope = super::super::derive::RekeyScope::ServerRoot;
4705        let relay = MemoryRelay::new();
4706
4707        // Epoch 1 → me (cites genesis).
4708        let root1 = [0x11u8; 32];
4709        let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root1).unwrap();
4710        let e1 = super::super::rekey::build_server_root_rekey_event(
4711            &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
4712            crate::community::Epoch(1), crate::community::Epoch(0),
4713            &super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), community.server_root_key.as_bytes()), &[b1],
4714        ).unwrap();
4715        // Epoch 2 → someone else (I'm removed), enveloped under root_1, cites root_1.
4716        let other = Keys::generate();
4717        let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
4718        let e2 = super::super::rekey::build_server_root_rekey_event(
4719            &Keys::generate(), &owner, &root1, &community.id,
4720            crate::community::Epoch(2), crate::community::Epoch(1),
4721            &super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &root1), &[b2],
4722        ).unwrap();
4723        relay.inject(&e1, &community.relays);
4724        relay.inject(&e2, &community.relays);
4725
4726        let reached = catch_up_server_root(&relay, &community).await.unwrap();
4727        assert_eq!(reached.epoch, 1, "stops at the last base epoch I was a recipient of");
4728        assert!(reached.removed, "excluded by an AUTHORIZED (owner) base rotation → flagged removed so the caller erases");
4729        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4730        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
4731    }
4732
4733    #[tokio::test]
4734    async fn catch_up_is_a_noop_with_no_rotations() {
4735        let (_tmp, _guard) = init_test_db();
4736        let owner = Keys::generate();
4737        let me = Keys::generate();
4738        become_local(&me);
4739        let community = saved_community_owned_by(&owner);
4740        let relay = MemoryRelay::new(); // empty: no rekeys published
4741        let reached = catch_up_channel_rekeys(&relay, &community, &community.channels[0].id).await.unwrap();
4742        assert_eq!(reached, 0, "no rotations → stays at the held epoch");
4743    }
4744
4745    #[tokio::test]
4746    async fn catch_up_stops_when_removed_midway() {
4747        // I'm a recipient of epoch 1 but NOT epoch 2 (removed). Catch-up applies epoch 1, finds no blob
4748        // for epoch 2 (NotARecipient), and stops — head at 1, not dragged forward to a key I lack.
4749        let (_tmp, _guard) = init_test_db();
4750        let owner = Keys::generate();
4751        let me = Keys::generate();
4752        become_local(&me);
4753        let community = saved_community_owned_by(&owner);
4754        let channel_id = community.channels[0].id;
4755        let chan = &community.channels[0];
4756        let scope = super::super::derive::RekeyScope::Channel(channel_id);
4757        let relay = MemoryRelay::new();
4758
4759        // Epoch 1: blob for me (cites genesis).
4760        let k1 = [0x11u8; 32];
4761        let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
4762        let e1 = super::super::rekey::build_channel_rekey_event(
4763            &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4764            crate::community::Epoch(1), crate::community::Epoch(0),
4765            &super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes()), &[b1],
4766        ).unwrap();
4767        // Epoch 2: blob for SOMEONE ELSE (I was removed) — cites k1.
4768        let other = Keys::generate();
4769        let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
4770        let e2 = super::super::rekey::build_channel_rekey_event(
4771            &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4772            crate::community::Epoch(2), crate::community::Epoch(1),
4773            &super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1), &[b2],
4774        ).unwrap();
4775        relay.inject(&e1, &community.relays);
4776        relay.inject(&e2, &community.relays);
4777
4778        let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4779        assert_eq!(reached, 1, "stops at the last epoch I was a recipient of");
4780        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4781        assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
4782    }
4783
4784    #[tokio::test]
4785    async fn rotate_channel_rejects_unauthorized() {
4786        let (_tmp, _guard) = init_test_db();
4787        let owner = Keys::generate();
4788        let rogue = Keys::generate();
4789        become_local(&rogue); // not the owner, holds no role
4790        let community = saved_community_owned_by(&owner);
4791        let relay = MemoryRelay::new();
4792        assert!(
4793            rotate_channel(&relay, &community, &community.channels[0].id, &[], community.server_root_key.as_bytes()).await.is_err(),
4794            "a non-authorized member cannot rotate"
4795        );
4796        // My head did not move.
4797        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4798        assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
4799    }
4800
4801    // --- rotate_server_root (#4c) ---
4802
4803    #[tokio::test]
4804    async fn rotate_server_root_publishes_recoverable_rekey_and_advances_base() {
4805        use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
4806        use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
4807        let (_tmp, _guard) = init_test_db();
4808        let owner = Keys::generate();
4809        become_local(&owner); // owner is supreme (holds BAN)
4810        let community = saved_community_owned_by(&owner);
4811        let genesis_root = *community.server_root_key.as_bytes();
4812        let member = Keys::generate();
4813        let relay = MemoryRelay::new();
4814
4815        let new_epoch = rotate_server_root(&relay, &community, &[member.public_key()]).await.expect("rotate base");
4816        assert_eq!(new_epoch, 1);
4817
4818        // Owner's base head advanced to a fresh root.
4819        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4820        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
4821        assert_ne!(reloaded.server_root_key.as_bytes(), &genesis_root, "base root is fresh-random, not the genesis");
4822
4823        // The base rekey is found at the PRIOR-root-derived address and opens under the PRIOR (genesis) root.
4824        let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
4825        let found = relay
4826            .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4827            .await
4828            .unwrap();
4829        assert_eq!(found.len(), 1, "base rekey addressable by its prior-root pseudonym");
4830        let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
4831        assert!(matches!(parsed.scope, crate::community::derive::RekeyScope::ServerRoot));
4832        assert_eq!(parsed.rotator, owner.public_key());
4833        assert_eq!(parsed.blobs.len(), 2, "member + me (multi-device)");
4834
4835        // The member recovers a root, and it equals the owner's advanced base head (one source of truth).
4836        let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
4837        let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
4838        let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
4839        let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
4840        assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "member's recovered root == owner's advanced base head");
4841    }
4842
4843    #[tokio::test]
4844    async fn rotate_server_root_failed_publish_leaves_base_unadvanced() {
4845        let (_tmp, _guard) = init_test_db();
4846        let owner = Keys::generate();
4847        become_local(&owner);
4848        let community = saved_community_owned_by(&owner);
4849        let member = Keys::generate();
4850        assert!(rotate_server_root(&FailingRelay, &community, &[member.public_key()]).await.is_err());
4851        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4852        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head stays put on publish failure");
4853    }
4854
4855    #[tokio::test]
4856    async fn rotate_server_root_dedups_self_in_recipients() {
4857        // Passing my own pubkey in `recipients` must not produce a duplicate blob (I'm always added).
4858        use crate::community::rekey::open_rekey_event;
4859        let (_tmp, _guard) = init_test_db();
4860        let owner = Keys::generate();
4861        become_local(&owner);
4862        let community = saved_community_owned_by(&owner);
4863        let relay = MemoryRelay::new();
4864        rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
4865        let addr = crate::community::derive::base_rekey_pseudonym(
4866            &crate::community::ServerRootKey(*community.server_root_key.as_bytes()), &community.id, crate::community::Epoch(1),
4867        )
4868        .to_hex();
4869        let found = relay
4870            .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4871            .await
4872            .unwrap();
4873        let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
4874        assert_eq!(parsed.blobs.len(), 1, "self listed in recipients yields exactly one blob, not two");
4875    }
4876
4877    #[tokio::test]
4878    async fn rotate_server_root_rejects_unauthorized() {
4879        let (_tmp, _guard) = init_test_db();
4880        let owner = Keys::generate();
4881        let rogue = Keys::generate();
4882        become_local(&rogue); // no BAN, not owner
4883        let community = saved_community_owned_by(&owner);
4884        let relay = MemoryRelay::new();
4885        assert!(rotate_server_root(&relay, &community, &[]).await.is_err(), "a non-BAN member cannot rotate the base");
4886        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4887        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0));
4888    }
4889
4890    #[tokio::test]
4891    async fn rotate_server_root_reanchors_the_control_plane_to_the_new_epoch() {
4892        // #4e-2 orchestration: a base rotation carries the control plane to the new epoch as part of the
4893        // SAME operation — a member reading the new root reaches the roster without a separate step.
4894        let (_tmp, _guard) = init_test_db();
4895        let relay = MemoryRelay::new();
4896        // create publishes 3 genesis editions (GroupRoot + #general ChannelMetadata + Admin role) and
4897        // records all three heads.
4898        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4899        let cid = community.id.to_hex();
4900        assert_eq!(crate::db::community::edition_head_entity_ids(&cid).unwrap().len(), 3);
4901
4902        let member = Keys::generate();
4903        assert_eq!(rotate_server_root(&relay, &community, &[member.public_key()]).await.unwrap(), 1);
4904        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4905        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "base head advanced");
4906
4907        // The Admin role is reachable at the NEW epoch under the NEW root — re-anchored by the rotation.
4908        let z = crate::community::roster::control_pseudonym(&reloaded.server_root_key, &community.id, crate::community::Epoch(1));
4909        let evs = relay
4910            .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays)
4911            .await
4912            .unwrap();
4913        let inners: Vec<_> = evs
4914            .iter()
4915            .filter_map(|o| crate::community::roster::open_control_edition(o, &reloaded.server_root_key).ok())
4916            .collect();
4917        let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
4918        assert!(!folded.roles.roles.is_empty(), "control plane re-anchored at the new epoch as part of the rotation");
4919    }
4920
4921    #[tokio::test]
4922    async fn admin_refounding_carries_heads_verbatim_preserving_owner_and_peer_roles() {
4923        // The verbatim-heads payoff: a NON-OWNER admin re-founds, and because each head is re-wrapped (never
4924        // re-authored), the owner deed AND every peer admin's owner-signed grant ride along untouched — so
4925        // ownership and all roles survive, while the count compacts to one edition per entity.
4926        use crate::community::roles::Permissions;
4927        let (_tmp, _guard) = init_test_db();
4928        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
4929        let owner_hex = owner.public_key().to_hex();
4930        let relay = MemoryRelay::new();
4931        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4932        let cid = community.id.to_hex();
4933        let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
4934
4935        // Owner grants TWO admins (both grants OWNER-signed).
4936        let alice = Keys::generate();
4937        let bob = Keys::generate();
4938        set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
4939        set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
4940        let _ = fetch_and_apply_control(&relay, &community).await;
4941        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4942
4943        // Drive the GroupRoot ABOVE v1 with a real published edit, so this exercises verbatim-carry of a
4944        // >v1 head (it must keep its real version, NOT reset to v1) — not just a v1 genesis.
4945        let mut edited = community.clone();
4946        edited.name = "HQ renamed".into();
4947        republish_community_metadata(&relay, &edited).await.unwrap();
4948        let _ = fetch_and_apply_control(&relay, &community).await;
4949        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4950        assert!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0 >= 2, "GroupRoot now above v1");
4951
4952        // ALICE (a non-owner admin) re-founds. She holds BAN, so it's authorized; she re-WRAPS heads.
4953        become_local(&alice);
4954        let new_epoch = rotate_server_root(&relay, &community, &[owner.public_key(), bob.public_key()]).await.unwrap();
4955        assert_eq!(new_epoch, 1);
4956        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4957        assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
4958
4959        // Fold the new epoch fresh (floor 0): owner unchanged + BOTH alice and bob still admins.
4960        let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(1));
4961        let evs = relay.fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays).await.unwrap();
4962        let inners: Vec<_> = evs.iter().filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok()).collect();
4963        let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
4964        let authed = crate::community::roster::authorize_delegation(&folded, Some(&owner_hex));
4965        assert!(authed.is_authorized(&alice.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "alice (re-founder) still admin");
4966        assert!(authed.is_authorized(&bob.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "bob (peer admin) NOT demoted by alice's re-founding");
4967        let new_owner = folded.root_meta.as_ref().and_then(|m| m.owner_attestation.as_ref())
4968            .and_then(|j| Event::from_json(j).ok()).map(|e| e.pubkey.to_hex());
4969        assert_eq!(new_owner.as_deref(), Some(owner_hex.as_str()), "owner deed carried verbatim — ownership intact after an admin re-founding");
4970        assert_eq!(folded.root_meta.as_ref().map(|m| m.name.as_str()), Some("HQ renamed"),
4971            "the >v1 GroupRoot head carried verbatim (content preserved across the re-founding)");
4972        // Compacted: each entity appears at most once at the new epoch.
4973        let mut per_entity: std::collections::HashMap<[u8; 32], usize> = std::collections::HashMap::new();
4974        for i in &inners {
4975            if let Ok(p) = crate::community::edition::parse_edition_inner(i) { *per_entity.entry(p.entity_id).or_default() += 1; }
4976        }
4977        assert!(per_entity.values().all(|&c| c == 1), "one edition per entity at the new epoch (compacted)");
4978    }
4979
4980    /// Block-until-synced: an admin write (rekey) is REFUSED when we're network-isolated — no relay returns
4981    /// the control plane we KNOW exists (we hold edition heads). Acting blind on a stale view, or advancing
4982    /// local state we can't publish, must not happen offline.
4983    #[tokio::test]
4984    async fn admin_write_blocked_when_isolated() {
4985        let (_tmp, _guard) = init_test_db();
4986        let me = Keys::generate();
4987        become_local(&me);
4988        let community = saved_community_owned_by(&me);
4989        let cid = community.id.to_hex();
4990        // We hold a local edition head → we KNOW a control plane exists (so an empty fetch = isolation).
4991        crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[1u8; 32], &[1u8; 32]).unwrap();
4992        crate::db::community::set_read_cut_target_epoch(&cid, 1).unwrap();
4993        // FailingRelay.fetch returns Ok(empty) — the isolated case (no relay responds with anything).
4994        let err = reseal_base_to_observed(&FailingRelay, &community).await.unwrap_err();
4995        assert!(err.contains("offline") || err.contains("can't reach any relay"),
4996            "isolated admin write must fail closed, got: {err}");
4997        // Untouched: no base rotation happened.
4998        assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
4999            crate::community::Epoch(0), "no rotation while isolated");
5000    }
5001
5002    /// O2 — a re-founding rotates per-channel message keys too, not just the base. Without this a removed
5003    /// member holding a channel key keeps reading new messages (the base cut only covers control + @everyone).
5004    #[tokio::test]
5005    async fn refounding_rotates_channel_keys_too() {
5006        let (_tmp, _guard) = init_test_db();
5007        let relay = MemoryRelay::new();
5008        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5009        let channel_id = community.channels[0].id;
5010        assert_eq!(community.channels[0].epoch, crate::community::Epoch(0));
5011        assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
5012
5013        run_read_cut(&relay, &community, true).await.unwrap();
5014
5015        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
5016        assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "base rotated");
5017        let ch = after.channels.iter().find(|c| c.id == channel_id).unwrap();
5018        assert_eq!(ch.epoch, crate::community::Epoch(1), "channel key rotated too (O2)");
5019        assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&community.id.to_hex(), &channel_id.to_hex()).unwrap(),
5020            1, "channel marked rekeyed for the new base epoch");
5021        assert!(!crate::db::community::get_read_cut_pending(&community.id.to_hex()).unwrap(),
5022            "a complete read-cut clears the pending flag");
5023    }
5024
5025    /// W2 durability — a re-founding interrupted AFTER the base rotated but BEFORE a channel rekey landed
5026    /// (outage / power cut / mass relay failure mid-cut) must RESUME, not restart: the retry skips the
5027    /// already-done base (no second epoch, no second control-plane re-anchor) and finishes only the
5028    /// un-rotated channel. Without resumability the retry double-rotated the base every time.
5029    #[tokio::test]
5030    async fn read_cut_resumes_without_double_base_rotation_after_channel_failure() {
5031        // Base + channel rekeys are both COMMUNITY_REKEY (3303); the base rekey is published BEFORE any
5032        // channel rekey, so the 1st 3303 is the base (allowed) and every later one is a channel (failed
5033        // while armed). Control re-anchor (3308) is always allowed.
5034        struct ChannelRekeyFails {
5035            inner: MemoryRelay,
5036            rekeys: std::sync::atomic::AtomicUsize,
5037            fail_channel: std::sync::atomic::AtomicBool,
5038        }
5039        #[async_trait::async_trait]
5040        impl Transport for ChannelRekeyFails {
5041            async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
5042            async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5043            async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5044                if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
5045                    let n = self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5046                    if n >= 1 && self.fail_channel.load(std::sync::atomic::Ordering::Relaxed) {
5047                        return Err("channel rekey relay down".into());
5048                    }
5049                }
5050                self.inner.publish_durable(e, r).await
5051            }
5052            async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
5053        }
5054        let (_tmp, _guard) = init_test_db();
5055        let relay = ChannelRekeyFails {
5056            inner: MemoryRelay::new(),
5057            rekeys: std::sync::atomic::AtomicUsize::new(0),
5058            fail_channel: std::sync::atomic::AtomicBool::new(true),
5059        };
5060        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5061        let channel_id = community.channels[0].id;
5062        let cid = community.id.to_hex();
5063        let ch_hex = channel_id.to_hex();
5064
5065        // Phase 1: base rotates, the channel rekey fails → the cut is left PENDING, base at epoch 1.
5066        assert!(run_read_cut(&relay, &community, true).await.is_err(), "the channel failure surfaces an error");
5067        let mid = crate::db::community::load_community(&community.id).unwrap().unwrap();
5068        assert_eq!(mid.server_root_epoch, crate::community::Epoch(1), "base advanced exactly once");
5069        assert_eq!(mid.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(0),
5070            "channel NOT rotated (its rekey failed)");
5071        assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "cut left pending after the failure");
5072        assert_eq!(crate::db::community::get_read_cut_target_epoch(&cid).unwrap(), 1, "target recorded durably");
5073        assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 0,
5074            "channel not yet marked for this cut");
5075
5076        // Phase 2: relay heals; the retry RESUMES — no second base rotation, just the leftover channel.
5077        relay.fail_channel.store(false, std::sync::atomic::Ordering::Relaxed);
5078        retry_pending_read_cut(&relay, &mid).await.unwrap();
5079        let done = crate::db::community::load_community(&community.id).unwrap().unwrap();
5080        assert_eq!(done.server_root_epoch, crate::community::Epoch(1),
5081            "base NOT rotated again — resumed at the same epoch (no double base rotation)");
5082        assert_eq!(done.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(1),
5083            "the un-rotated channel finished on resume");
5084        assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 1,
5085            "channel marked rekeyed for the cut epoch");
5086        assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the resume completes");
5087    }
5088
5089    #[tokio::test]
5090    async fn rotate_server_root_aborts_when_the_snapshot_does_not_land() {
5091        // Re-founding re-wraps the current heads, but a relay that won't ACK the re-wrapped control editions
5092        // leaves the snapshot incomplete → the rotation must abort with the base head NOT advanced (never
5093        // advance onto a plane no member folds).
5094        // Relay that ACKs everything UNTIL `fail` is set, then rejects control-edition (3308) publishes.
5095        struct ControlPublishFails { inner: MemoryRelay, fail: std::sync::atomic::AtomicBool }
5096        #[async_trait::async_trait]
5097        impl Transport for ControlPublishFails {
5098            async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
5099            async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5100            async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5101                if self.fail.load(std::sync::atomic::Ordering::Relaxed) && e.kind.as_u16() == event_kind::COMMUNITY_CONTROL {
5102                    return Err("control relay down".into());
5103                }
5104                self.inner.publish_durable(e, r).await
5105            }
5106            async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
5107        }
5108        let (_tmp, _guard) = init_test_db();
5109        let relay = ControlPublishFails { inner: MemoryRelay::new(), fail: std::sync::atomic::AtomicBool::new(false) };
5110        // Create normally (genesis editions publish + heads recorded), THEN start failing control publishes.
5111        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5112        relay.fail.store(true, std::sync::atomic::Ordering::Relaxed);
5113
5114        assert!(
5115            rotate_server_root(&relay, &community, &[]).await.is_err(),
5116            "a snapshot whose editions can't be re-published must abort the rotation"
5117        );
5118        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5119        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced when the snapshot doesn't land");
5120    }
5121
5122    #[tokio::test]
5123    async fn acquire_before_commit_a_reanchor_fetch_miss_publishes_no_base_rekey() {
5124        // #264 ACQUIRE-BEFORE-COMMIT: the re-anchor snapshot (the only mid-rekey fetch) is now fetched + sealed
5125        // BEFORE the base rekey is published. So a control-plane fetch miss (a head not propagated) aborts the
5126        // rotation with the base rekey NEVER on the wire — no half-published state to strand a member. Under the
5127        // old publish-first ordering the base rekey was already on relays when the fetch gate tripped.
5128        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5129        struct ReanchorFetchEmpty { inner: MemoryRelay, drop_control: AtomicBool, base_rekeys: AtomicUsize }
5130        #[async_trait::async_trait]
5131        impl Transport for ReanchorFetchEmpty {
5132            async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
5133            async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5134            async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5135                if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
5136                    self.base_rekeys.fetch_add(1, Ordering::Relaxed);
5137                }
5138                self.inner.publish_durable(e, r).await
5139            }
5140            async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
5141                if self.drop_control.load(Ordering::Relaxed) && q.kinds.iter().any(|k| *k == event_kind::COMMUNITY_CONTROL) {
5142                    return Ok(vec![]); // the re-anchor's heads are unreachable this instant
5143                }
5144                self.inner.fetch(q, r).await
5145            }
5146        }
5147        let (_tmp, _guard) = init_test_db();
5148        let relay = ReanchorFetchEmpty { inner: MemoryRelay::new(), drop_control: AtomicBool::new(false), base_rekeys: AtomicUsize::new(0) };
5149        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5150        relay.drop_control.store(true, Ordering::Relaxed);
5151
5152        assert!(rotate_server_root(&relay, &community, &[]).await.is_err(),
5153            "a re-anchor fetch miss must abort the rotation");
5154        assert_eq!(relay.base_rekeys.load(Ordering::Relaxed), 0,
5155            "the base rekey must NOT be published when the pre-publish fetch gate trips (acquire-before-commit)");
5156        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5157        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced");
5158    }
5159
5160    // --- reanchor_control_plane (#4e-1) ---
5161
5162    #[tokio::test]
5163    async fn reanchor_carries_role_and_grant_to_the_new_epoch_under_the_new_root() {
5164        let (_tmp, _guard) = init_test_db();
5165        let relay = MemoryRelay::new();
5166        // create_community publishes the auto Admin ROLE edition (3308) at the epoch-0 control pseudonym.
5167        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5168        let cid = community.id.to_hex();
5169        let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5170        let member = Keys::generate();
5171        // Compaction snapshots the LOCAL folded state, so seed the grant into it (publish + apply).
5172        set_member_grant(&relay, &community, &member.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5173        let _ = fetch_and_apply_control(&relay, &community).await;
5174        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5175
5176        // Re-anchor by COMPACTION to a fresh root + epoch 1: each entity re-genesised to v1.
5177        let new_root = [0x99u8; 32];
5178        let snap = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
5179        assert!(snap.iter().all(|e| e.published), "every snapshot edition published");
5180        assert_eq!(snap.len(), 4, "GroupRoot + channel + Admin role + grant compacted to v1");
5181
5182        // At the NEW epoch under the NEW root, the role + grant fold back (as fresh v1 geneses, community-scoped).
5183        let new_z = crate::community::roster::control_pseudonym(
5184            &crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
5185        );
5186        let after = relay
5187            .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
5188            .await
5189            .unwrap();
5190        let inners: Vec<_> = after
5191            .iter()
5192            .filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
5193            .collect();
5194        let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
5195        assert!(!folded.roles.roles.is_empty(), "Admin role reachable at the new epoch");
5196        assert!(
5197            folded.roles.grants.iter().any(|g| g.member == member.public_key().to_hex()),
5198            "grant carried to the new epoch under the new root"
5199        );
5200    }
5201
5202    #[tokio::test]
5203    async fn grant_after_a_rekey_survives_the_fold_at_the_new_epoch() {
5204        // REGRESSION (epoch consistency): a grant published AFTER a server-root rotation must seal at the
5205        // CURRENT epoch — where the re-anchored role definition now lives — and the fetch must look there
5206        // too. The bug: live publishes + the fetch hardcoded epoch 0 while the re-anchor moved the control
5207        // plane to the new epoch, so a post-rekey grant referenced a role the fetch never saw → the member
5208        // silently lost admin (exactly what we hit live).
5209        use crate::community::roles::Permissions;
5210        let (_tmp, _guard) = init_test_db();
5211        let relay = MemoryRelay::new();
5212        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5213        let cid = community.id.to_hex();
5214        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5215        let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5216
5217        // Rotate the base → epoch 1 (re-anchors the Admin role + GroupRoot under the new epoch).
5218        rotate_server_root(&relay, &community, &[owner.public_key()]).await.expect("rotate base");
5219        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5220        assert_eq!(community.server_root_epoch, crate::community::Epoch(1), "advanced to the new epoch");
5221
5222        // Grant Alice the Admin role NOW (post-rekey): the live publish seals at server_root_epoch (1).
5223        let alice = "aa".repeat(32);
5224        set_member_grant(&relay, &community, &alice, vec![admin_role_id]).await.unwrap();
5225
5226        // A fresh fetch+apply at the new epoch folds the re-anchored role AND the post-rekey grant TOGETHER.
5227        let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5228        assert!(
5229            roster.has_permission(&alice, Permissions::BAN),
5230            "post-rekey grant survives — Alice is Admin at the new epoch (pre-fix: dropped, role unreachable)"
5231        );
5232        assert_eq!(roster.highest_position(&alice), Some(1));
5233    }
5234
5235    /// Increment 2 — the demote AUTO-re-asserts: when the demoted member HEADS the GroupRoot, revoking
5236    /// them publishes an owner-authored re-assert of their content as the new head, so Concord Convergence
5237    /// keeps it for every client (incl. fresh joiners). End-to-end of the demote path.
5238    #[tokio::test]
5239    async fn demote_re_asserts_the_demoted_members_metadata_head() {
5240        let (_tmp, _guard) = init_test_db();
5241        let relay = MemoryRelay::new();
5242        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5243        let cid = community.id.to_hex();
5244        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5245        let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5246        let alice = Keys::generate();
5247        let alice_hex = alice.public_key().to_hex();
5248
5249        set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
5250        // Alice (admin) renames → she heads the GroupRoot.
5251        become_local(&alice);
5252        let mut as_alice = crate::db::community::load_community(&community.id).unwrap().unwrap();
5253        as_alice.name = "Alice's HQ".into();
5254        republish_community_metadata(&relay, &as_alice).await.unwrap();
5255        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5256        assert_eq!(
5257            fetch_control_folded(&relay, &community).await.unwrap().root_author.map(|a| a.to_hex()),
5258            Some(alice_hex.clone()), "alice heads the GroupRoot after her edit",
5259        );
5260
5261        // Owner demotes alice → auto re-assert.
5262        become_local(&owner);
5263        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5264        set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
5265
5266        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5267        let folded = fetch_control_folded(&relay, &community).await.unwrap();
5268        assert_eq!(folded.root_author.map(|a| a.to_hex()), Some(owner.public_key().to_hex()),
5269            "the demote re-asserted the GroupRoot under the owner");
5270        assert_eq!(folded.root_meta.as_ref().unwrap().name, "Alice's HQ",
5271            "the re-assert preserves the demoted member's content");
5272    }
5273
5274    /// Increment 2 — skip-if-not-head: demoting a member who does NOT head the GroupRoot publishes no
5275    /// re-assert (zero unnecessary editions — the common case). The owner made the last edit here.
5276    #[tokio::test]
5277    async fn demote_skips_reassert_when_member_does_not_head() {
5278        let (_tmp, _guard) = init_test_db();
5279        let relay = MemoryRelay::new();
5280        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5281        let cid = community.id.to_hex();
5282        let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5283        let alice = Keys::generate();
5284        let alice_hex = alice.public_key().to_hex();
5285
5286        set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
5287        // OWNER makes the last metadata edit → the owner heads it, not alice.
5288        let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
5289        c.name = "Owner's HQ".into();
5290        republish_community_metadata(&relay, &c).await.unwrap();
5291        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5292        let before = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
5293
5294        set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
5295        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5296        let after = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
5297        assert_eq!(after, before, "no re-assert published — the demoted member didn't head the GroupRoot");
5298    }
5299
5300    #[tokio::test]
5301    async fn reanchor_carries_the_banlist_edition_to_the_new_epoch() {
5302        // The banlist is now a 3308 edition at the community-scoped banlist locator, so re-anchoring
5303        // (kind-agnostic within 3308) carries it forward — a post-rotation joiner gets the current bans.
5304        let (_tmp, _guard) = init_test_db();
5305        let relay = MemoryRelay::new();
5306        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5307        let carol = "cc".repeat(32);
5308        // Seed the banlist into LOCAL state (publish + apply), since compaction snapshots the local set.
5309        publish_banlist(&relay, &community, &[carol.clone()]).await.unwrap();
5310        let _ = fetch_and_apply_control(&relay, &community).await;
5311        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5312
5313        // Re-anchor by COMPACTION to a fresh root + epoch 1: the banlist is re-genesised forward.
5314        let new_root = [0x99u8; 32];
5315        let n = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
5316        assert!(n.iter().all(|e| e.published), "every snapshot edition published");
5317        assert_eq!(n.len(), 4, "GroupRoot + channel + Admin role + banlist compacted to v1");
5318
5319        // Fetch at the new epoch under the new root → the banlist folds back with Carol still banned.
5320        let new_z = crate::community::roster::control_pseudonym(
5321            &crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
5322        );
5323        let after = relay
5324            .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
5325            .await
5326            .unwrap();
5327        let inners: Vec<_> = after
5328            .iter()
5329            .filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
5330            .collect();
5331        let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
5332        assert_eq!(folded.banned, vec![carol], "banlist reachable at the new epoch under the new root");
5333    }
5334
5335    // --- apply_server_root_rekey (#4b) ---
5336
5337    /// An owner-authored base rekey to `new_epoch` carrying one ServerRoot blob for `recipient_pk`,
5338    /// citing the community's current (genesis epoch-0) root. Returns the opened ParsedRekey.
5339    fn owner_base_rekey(
5340        owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::prelude::PublicKey, new_epoch: u64, new_root: &[u8; 32],
5341    ) -> super::super::rekey::ParsedRekey {
5342        let prev = community.server_root_epoch.0;
5343        let blob = super::super::rekey::build_rekey_blob(
5344            owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(new_epoch), new_root,
5345        )
5346        .unwrap();
5347        let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(prev), community.server_root_key.as_bytes());
5348        let outer = super::super::rekey::build_server_root_rekey_event(
5349            &Keys::generate(), owner, community.server_root_key.as_bytes(), &community.id,
5350            crate::community::Epoch(new_epoch), crate::community::Epoch(prev), &commit, &[blob],
5351        )
5352        .unwrap();
5353        super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
5354    }
5355
5356    #[test]
5357    fn apply_server_root_rekey_recovers_new_root_and_advances_base() {
5358        let (_tmp, _guard) = init_test_db();
5359        let owner = Keys::generate();
5360        let me = Keys::generate();
5361        become_local(&me);
5362        let community = saved_community_owned_by(&owner);
5363        let cid = community.id.to_hex();
5364        let new_root = [0xCDu8; 32];
5365
5366        let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &new_root);
5367        assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5368
5369        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5370        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
5371        assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "base head advanced to the new root");
5372        // Genesis root retained (cross-epoch control/base history stays decryptable).
5373        assert!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().is_some());
5374        assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(), Some(new_root));
5375    }
5376
5377    #[test]
5378    fn apply_server_root_rekey_not_a_recipient_leaves_base_unchanged() {
5379        let (_tmp, _guard) = init_test_db();
5380        let owner = Keys::generate();
5381        let me = Keys::generate();
5382        become_local(&me);
5383        let community = saved_community_owned_by(&owner);
5384        let other = Keys::generate(); // blob wrapped to someone else → I was removed in this rotation
5385        let parsed = owner_base_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
5386        assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
5387        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5388        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "removed-from-base member's head unchanged");
5389    }
5390
5391    #[test]
5392    fn apply_server_root_rekey_rejects_rotator_without_ban() {
5393        let (_tmp, _guard) = init_test_db();
5394        let owner = Keys::generate();
5395        let me = Keys::generate();
5396        become_local(&me);
5397        let community = saved_community_owned_by(&owner);
5398        // A rotator who is neither owner nor BAN-ranked cannot rotate the base.
5399        let rogue = Keys::generate();
5400        let parsed = owner_base_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
5401        assert!(apply_server_root_rekey(&community, &parsed).is_err(), "unauthorized base rotation rejected");
5402    }
5403
5404    #[test]
5405    fn apply_server_root_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
5406        // BASE FORK-CONVERGENCE (mirrors the channel reorg): I hold the genesis root, but an AUTHORIZED
5407        // (owner, BAN) epoch-1 base rekey continues from a DIFFERENT epoch-0 root (I lost a concurrent
5408        // re-founding). It must be ADOPTED — converge forward onto the authorized chain — not rejected and
5409        // left to stall every later base rotation. Authority + ECDH recipiency are the gates, not continuity.
5410        let (_tmp, _guard) = init_test_db();
5411        let owner = Keys::generate();
5412        let me = Keys::generate();
5413        become_local(&me);
5414        let community = saved_community_owned_by(&owner);
5415        let blob = super::super::rekey::build_rekey_blob(
5416            owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(1), &[0x33u8; 32],
5417        )
5418        .unwrap();
5419        // Commit over a WRONG prior root (not the genesis I hold) → continuity mismatch (the losing fork).
5420        let bad = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
5421        let outer = super::super::rekey::build_server_root_rekey_event(
5422            &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
5423            crate::community::Epoch(1), crate::community::Epoch(0), &bad, &[blob],
5424        )
5425        .unwrap();
5426        let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5427        let outcome = apply_server_root_rekey(&community, &parsed);
5428        assert!(
5429            matches!(outcome, Ok(RekeyOutcome::Applied { .. })),
5430            "an authorized base chain must be adopted (reorg), not rejected as foreign; got {outcome:?}"
5431        );
5432    }
5433
5434    #[test]
5435    fn apply_server_root_rekey_catchup_archives_without_regressing_base_head() {
5436        // Parity with the channel no-regress test: applying an OLDER base epoch archives its root but
5437        // must not regress the base head (the forward-walk can deliver out of order).
5438        let (_tmp, _guard) = init_test_db();
5439        let owner = Keys::generate();
5440        let me = Keys::generate();
5441        become_local(&me);
5442        let community = saved_community_owned_by(&owner);
5443        let cid = community.id.to_hex();
5444
5445        let r5 = [0x55u8; 32];
5446        let p5 = owner_base_rekey(&owner, &community, &me.public_key(), 5, &r5);
5447        assert_eq!(apply_server_root_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5448        let r3 = [0x33u8; 32];
5449        let p3 = owner_base_rekey(&owner, &community, &me.public_key(), 3, &r3);
5450        assert_eq!(apply_server_root_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
5451
5452        assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 3).unwrap(), Some(r3));
5453        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5454        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5), "base head stayed at newest");
5455        assert_eq!(reloaded.server_root_key.as_bytes(), &r5);
5456    }
5457
5458    #[test]
5459    fn apply_server_root_rekey_authorizes_a_granted_ban_admin() {
5460        // role-based: a non-owner who holds a role carrying BAN may rotate the base. Re-founding re-wraps
5461        // each head verbatim (never re-authors), so an admin re-founder can't demote peers or steal ownership
5462        // — which is exactly why this stays BAN-gated rather than owner-only.
5463        let (_tmp, _guard) = init_test_db();
5464        let owner = Keys::generate();
5465        let me = Keys::generate();
5466        become_local(&me);
5467        let community = saved_community_owned_by(&owner);
5468        let cid = community.id.to_hex();
5469
5470        let admin = Keys::generate();
5471        let role_id = "d".repeat(64);
5472        let roster = crate::community::roles::CommunityRoles {
5473            roles: vec![crate::community::roles::Role::admin(role_id.clone())],
5474            grants: vec![crate::community::roles::MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role_id] }],
5475        };
5476        crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
5477
5478        let parsed = owner_base_rekey(&admin, &community, &me.public_key(), 1, &[0x77u8; 32]);
5479        assert_eq!(
5480            apply_server_root_rekey(&community, &parsed).unwrap(),
5481            RekeyOutcome::Applied { head_advanced: true },
5482            "a BAN-granted admin (not the owner) can rotate the base"
5483        );
5484    }
5485
5486    #[test]
5487    fn apply_server_root_rekey_accepts_when_prior_root_not_held() {
5488        // Catch-up from further back: a base rekey citing a prior epoch whose root I don't hold skips
5489        // the continuity check (ECDH blob + authority still authenticate) and applies.
5490        let (_tmp, _guard) = init_test_db();
5491        let owner = Keys::generate();
5492        let me = Keys::generate();
5493        become_local(&me);
5494        let community = saved_community_owned_by(&owner);
5495
5496        let new_root = [0x99u8; 32];
5497        let blob = super::super::rekey::build_rekey_blob(
5498            owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(5), &new_root,
5499        )
5500        .unwrap();
5501        // Cites epoch 4 (whose root I never held); commitment is over a root I don't have.
5502        let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(4), &[0xEEu8; 32]);
5503        let outer = super::super::rekey::build_server_root_rekey_event(
5504            &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
5505            crate::community::Epoch(5), crate::community::Epoch(4), &commit, &[blob],
5506        )
5507        .unwrap();
5508        let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5509        assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5510        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5511        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5));
5512    }
5513
5514    #[test]
5515    fn apply_server_root_rekey_rejects_channel_scope() {
5516        // A channel-scoped rekey must NOT be applied as a base rotation (fail closed).
5517        let (_tmp, _guard) = init_test_db();
5518        let owner = Keys::generate();
5519        let me = Keys::generate();
5520        become_local(&me);
5521        let community = saved_community_owned_by(&owner);
5522        let channel_parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
5523        assert!(apply_server_root_rekey(&community, &channel_parsed).is_err(), "channel scope rejected by base apply");
5524    }
5525
5526    #[test]
5527    fn apply_channel_rekey_not_a_recipient() {
5528        let (_tmp, _guard) = init_test_db();
5529        let owner = Keys::generate();
5530        let me = Keys::generate();
5531        become_local(&me);
5532        let community = saved_community_owned_by(&owner);
5533        // The blob is wrapped to SOMEONE ELSE, so my locator finds nothing.
5534        let other = Keys::generate();
5535        let parsed = owner_channel_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
5536        assert_eq!(apply_channel_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
5537        // Nothing committed: head stays at epoch 0.
5538        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5539        assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
5540    }
5541
5542    #[test]
5543    fn apply_channel_rekey_rejects_unauthorized_rotator() {
5544        let (_tmp, _guard) = init_test_db();
5545        let owner = Keys::generate();
5546        let me = Keys::generate();
5547        become_local(&me);
5548        let community = saved_community_owned_by(&owner);
5549        // A rotator who is NEITHER the owner NOR holds MANAGE_CHANNELS in the (empty) roster.
5550        let rogue = Keys::generate();
5551        let parsed = owner_channel_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
5552        assert!(apply_channel_rekey(&community, &parsed).is_err(), "unauthorized rotation must be rejected");
5553    }
5554
5555    #[test]
5556    fn apply_channel_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
5557        // FORK-CONVERGENCE ("reorg"): I hold genesis epoch-0, but an AUTHORIZED (owner) epoch-1 rekey
5558        // cites a DIFFERENT epoch-0 key (a chain I'm not on) and delivers epoch-1 to ME. Authority (checked
5559        // first) + recipient (the blob opens) are the real gates, so I REORG forward onto the authorized
5560        // chain instead of rejecting + stranding myself. (The commitment is continuity, not security — it
5561        // yields to convergence. An UNAUTHORIZED rotator with the same mismatch is still rejected by the
5562        // authority gate; see apply_channel_rekey_rejects_unauthorized_rotation.)
5563        let (_tmp, _guard) = init_test_db();
5564        let owner = Keys::generate();
5565        let me = Keys::generate();
5566        become_local(&me);
5567        let community = saved_community_owned_by(&owner);
5568        let chan = &community.channels[0];
5569        let scope = super::super::derive::RekeyScope::Channel(chan.id);
5570        let new_key = [0x33u8; 32];
5571        let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &new_key).unwrap();
5572        // Commit over a DIFFERENT prior key than the genesis I hold → a divergent prior epoch (a fork).
5573        let other_commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
5574        let outer = super::super::rekey::build_channel_rekey_event(
5575            &Keys::generate(), &owner, community.server_root_key.as_bytes(), &chan.id,
5576            crate::community::Epoch(1), crate::community::Epoch(0), &other_commit, &[blob],
5577        )
5578        .unwrap();
5579        let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5580        let outcome = apply_channel_rekey(&community, &parsed).unwrap();
5581        assert!(matches!(outcome, RekeyOutcome::Applied { .. }),
5582            "an authorized chain must be adopted (reorg), not rejected as foreign; got {outcome:?}");
5583        assert_eq!(crate::db::community::held_epoch_key(&community.id.to_hex(), &chan.id.to_hex(), 1).unwrap(), Some(new_key));
5584    }
5585
5586    #[test]
5587    fn apply_channel_rekey_catchup_archives_without_regressing_head() {
5588        let (_tmp, _guard) = init_test_db();
5589        let owner = Keys::generate();
5590        let me = Keys::generate();
5591        become_local(&me);
5592        let community = saved_community_owned_by(&owner);
5593        let cid = community.id.to_hex();
5594        let chan_hex = community.channels[0].id.to_hex();
5595
5596        // Apply epoch 5 first → head advances to 5.
5597        let k5 = [0x55u8; 32];
5598        let p5 = owner_channel_rekey(&owner, &community, &me.public_key(), 5, &k5);
5599        assert_eq!(apply_channel_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5600        // Now apply an OLDER epoch 3 (catch-up) → archived, but head must NOT regress.
5601        let k3 = [0x33u8; 32];
5602        let p3 = owner_channel_rekey(&owner, &community, &me.public_key(), 3, &k3);
5603        assert_eq!(apply_channel_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
5604
5605        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(k3), "old epoch archived");
5606        assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 5).unwrap(), Some(k5));
5607        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5608        assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(5), "head stayed at the newest epoch");
5609        assert_eq!(reloaded.channels[0].key.as_bytes(), &k5);
5610    }
5611
5612    #[tokio::test]
5613    async fn create_community_persists_and_publishes_metadata() {
5614        use crate::community::transport::Query;
5615        use crate::stored_event::event_kind;
5616
5617        let (_tmp, _guard) = init_test_db();
5618        let relay = MemoryRelay::new();
5619        let community = create_community(&relay, "Vector HQ", "general", vec!["r1".into()])
5620            .await
5621            .expect("create");
5622
5623        // Returned shape.
5624        assert_eq!(community.name, "Vector HQ");
5625        assert_eq!(community.channels.len(), 1);
5626        assert_eq!(community.channels[0].name, "general");
5627
5628        // Persisted locally (reloadable with matching keys).
5629        let loaded = crate::db::community::load_community(&community.id).unwrap().expect("persisted");
5630        assert_eq!(loaded.channels[0].name, "general");
5631        assert_eq!(loaded.server_root_key.as_bytes(), community.server_root_key.as_bytes());
5632
5633        // GroupRoot + ChannelMetadata are 3308 editions on the control plane, keyless
5634        // (the actor's inner real-npub signature is the authority proof).
5635        let meta_events = relay
5636            .fetch(
5637                &Query { kinds: vec![event_kind::APPLICATION_SPECIFIC], ..Default::default() },
5638                &community.relays,
5639            )
5640            .await
5641            .unwrap();
5642        assert!(meta_events.is_empty(), "no legacy 30078 metadata events");
5643
5644        // The control plane carries THREE genesis editions, all real-npub signed by the OWNER: the
5645        // GroupRoot (vsk=0), the #general ChannelMetadata (vsk=2), and the auto Admin role (vsk=1).
5646        let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
5647        let control = relay
5648            .fetch(
5649                &Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() },
5650                &community.relays,
5651            )
5652            .await
5653            .unwrap();
5654        assert_eq!(control.len(), 3, "GroupRoot + ChannelMetadata + Admin role editions");
5655        let owner_pk = crate::state::my_public_key().unwrap();
5656        let parsed: Vec<_> = control
5657            .iter()
5658            .filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok())
5659            .filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
5660            .collect();
5661        assert!(parsed.iter().all(|p| p.author == owner_pk), "every genesis edition authored by the owner");
5662        // The GroupRoot edition (vsk=0) carries the community name + owner attestation.
5663        let root = parsed.iter().find(|p| p.entity_id == community.id.0).expect("GroupRoot edition");
5664        let root_meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&root.content).unwrap();
5665        assert_eq!(root_meta.name, "Vector HQ");
5666        assert!(root_meta.owner_attestation.is_some());
5667        // The Admin role edition (vsk=1) is the genesis of the Admin chain.
5668        let role: crate::community::roles::Role = parsed
5669            .iter()
5670            .find_map(|p| serde_json::from_str::<crate::community::roles::Role>(&p.content).ok().filter(|r| r.name == "Admin"))
5671            .expect("Admin role edition");
5672        assert_eq!(role.position, 1);
5673        assert!(role.permissions.contains(crate::community::roles::Permissions::ADMIN_ALL));
5674
5675        // Cached locally too (the owner's client immediately knows the Admin role exists).
5676        let cached = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap();
5677        assert_eq!(cached.roles.len(), 1);
5678        assert!(cached.grants.is_empty(), "owner is implicit position 0, takes no grant");
5679    }
5680
5681    #[tokio::test]
5682    async fn role_grant_round_trips_through_relays_and_revokes() {
5683        use crate::community::roles::Permissions;
5684        let (_tmp, _guard) = init_test_db();
5685        let relay = MemoryRelay::new();
5686        let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5687            .await
5688            .expect("create");
5689        let cid = community.id.to_hex();
5690        let alice = "aa".repeat(32);
5691        let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0]
5692            .role_id
5693            .clone();
5694
5695        // Owner grants Alice the Admin role.
5696        set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()])
5697            .await
5698            .unwrap();
5699        assert!(
5700            crate::db::community::get_community_roles(&cid).unwrap().is_privileged(&alice),
5701            "local cache reflects the grant immediately"
5702        );
5703
5704        // A fresh fetch+apply reconstructs the whole graph from the relays: Alice is a BAN-capable
5705        // Admin, and the role definition came back too.
5706        let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5707        assert!(roster.has_permission(&alice, Permissions::BAN));
5708        assert!(roster.has_permission(&alice, Permissions::MANAGE_ROLES));
5709        assert_eq!(roster.roles.len(), 1);
5710        assert_eq!(roster.highest_position(&alice), Some(1));
5711
5712        // Revoke (empty grant) → Alice loses the role and the empty grant is pruned from the cache.
5713        set_member_grant(&relay, &community, &alice, vec![]).await.unwrap();
5714        let after = crate::db::community::get_community_roles(&cid).unwrap();
5715        assert!(!after.is_privileged(&alice), "revoked member holds no role");
5716        assert!(after.grants.is_empty(), "empty grant pruned");
5717    }
5718
5719    #[tokio::test]
5720    async fn admin_cannot_grant_a_peer_rank_role() {
5721        // escalation defense at the authoring gate: an Admin (position 1) may NOT grant the Admin
5722        // role (also position 1) — equal can't escalate equal. Only the owner (position 0, strictly
5723        // above) can. Closes the raw-command path even though the MVP UI gates the toggle on owner.
5724        let (_tmp, _guard) = init_test_db();
5725        let relay = MemoryRelay::new();
5726        let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5727            .await
5728            .expect("create");
5729        let cid = community.id.to_hex();
5730        let admin_role_id =
5731            crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5732        let alice = Keys::generate();
5733        // Owner seeds Alice as an Admin (set_member_grant is the low-level write, not the gated action).
5734        set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5735            .await
5736            .unwrap();
5737
5738        // Now ACT as Alice and try to grant the Admin role to Bob — refused.
5739        crate::state::set_my_public_key(alice.public_key());
5740        let bob = Keys::generate().public_key();
5741        let err = grant_role(&relay, &community, bob, &admin_role_id).await.unwrap_err();
5742        assert!(err.contains("below your own"), "peer-rank grant refused, got: {err}");
5743    }
5744
5745    #[tokio::test]
5746    async fn create_community_mints_a_verifiable_owner_attestation() {
5747        // The owner attestation is mandatory at creation (no root → no community) and must prove the
5748        // creator as owner, bound to this community.
5749        let (_tmp, _guard) = init_test_db();
5750        let me = crate::state::my_public_key().unwrap();
5751        let relay = MemoryRelay::new();
5752        let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5753            .await
5754            .expect("create");
5755        let att = community.owner_attestation.as_ref().expect("attestation is mandatory");
5756        let proven = super::super::owner::verify_owner_attestation(att, &community.id.to_hex());
5757        assert_eq!(proven, Some(me), "the creator is the proven owner");
5758        // It can't be transplanted to a different community id.
5759        assert_eq!(
5760            super::super::owner::verify_owner_attestation(att, &"f".repeat(64)),
5761            None,
5762        );
5763    }
5764
5765    #[tokio::test]
5766    async fn admin_cannot_ban_a_peer_admin() {
5767        // hierarchy at the banlist gate: an Admin (pos 1, holds BAN) cannot ban a *peer* Admin
5768        // (also pos 1) — equal can't act on equal; only someone strictly above (the owner) can. Closes
5769        // the B1 sibling hole (the outrank gate had been wired on grant/revoke but not on the banlist).
5770        let (_tmp, _guard) = init_test_db();
5771        let relay = MemoryRelay::new();
5772        let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5773            .await
5774            .expect("create");
5775        let cid = community.id.to_hex();
5776        let admin_role_id =
5777            crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5778        let alice = Keys::generate();
5779        let bob = Keys::generate();
5780        // Owner seeds both as Admins (set_member_grant is the low-level write, not the gated action).
5781        set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5782            .await
5783            .unwrap();
5784        set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
5785            .await
5786            .unwrap();
5787
5788        // Act as Alice (the edition is signed by the vault identity → become her, not just set the
5789        // pubkey): she may NOT ban peer-admin Bob (rejected at the gate, before any signing).
5790        become_local(&alice);
5791        let err = publish_banlist(&relay, &community, &[bob.public_key().to_hex()])
5792            .await
5793            .unwrap_err();
5794        assert!(err.contains("outranks you"), "peer-admin ban refused, got: {err}");
5795    }
5796
5797    #[tokio::test]
5798    async fn roster_reconstructs_purely_from_relay() {
5799        // Prove the fetch path reconstructs from the relay editions, NOT the optimistic local cache:
5800        // publish the role + a grant, WIPE the local roster cache, then fetch — a populated result
5801        // can then only have come from the relay.
5802        let (_tmp, _guard) = init_test_db();
5803        let relay = MemoryRelay::new();
5804        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5805        let cid = community.id.to_hex();
5806        let admin_role_id =
5807            crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5808        let alice = "aa".repeat(32);
5809        set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()]).await.unwrap();
5810
5811        // Wipe the local cache so a populated result can ONLY come from the relay.
5812        crate::db::community::set_community_roles(&cid, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
5813        assert!(crate::db::community::get_community_roles(&cid).unwrap().roles.is_empty(), "cache wiped");
5814
5815        let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5816        assert!(roster.is_admin(&alice), "roster reconstructed from relay editions, not the cache");
5817        assert_eq!(roster.roles.len(), 1, "the Admin role edition folded back");
5818    }
5819
5820    #[tokio::test]
5821    async fn admin_cannot_unban_a_peer_admin() {
5822        // hierarchy on the REMOVAL side: an Admin can't unban (drop from the banlist) a peer Admin
5823        // the owner banned — gating only additions would let a low admin undo a superior's ban.
5824        let (_tmp, _guard) = init_test_db();
5825        let relay = MemoryRelay::new();
5826        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5827        let cid = community.id.to_hex();
5828        let admin_role_id =
5829            crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5830        let alice = Keys::generate();
5831        let bob = Keys::generate();
5832        set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5833            .await
5834            .unwrap();
5835        set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
5836            .await
5837            .unwrap();
5838        // Owner banned peer-admin Bob (seed the banlist directly).
5839        crate::db::community::set_community_banlist(&cid, &[bob.public_key().to_hex()], 1000).unwrap();
5840
5841        // Alice (admin) tries to clear the banlist → unbanning peer-admin Bob is refused.
5842        become_local(&alice);
5843        let err = publish_banlist(&relay, &community, &[]).await.unwrap_err();
5844        assert!(err.contains("unban"), "unbanning a peer admin refused, got: {err}");
5845    }
5846
5847    #[tokio::test]
5848    async fn create_community_rejects_signer_identity_mismatch() {
5849        // The vault must hold the ACTIVE identity's key to sign the attestation locally. If the active
5850        // pubkey differs from the vault key (a stale/half-swapped session) and there's no bunker
5851        // client, creation fails rather than minting an attestation owned by the wrong identity.
5852        let (_tmp, _guard) = init_test_db(); // seeds matching vault key + my_public_key
5853        let other = Keys::generate();
5854        crate::state::set_my_public_key(other.public_key()); // force a mismatch
5855        let relay = MemoryRelay::new();
5856        let err = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap_err();
5857        assert!(err.contains("identity signer"), "signer mismatch refused, got: {err}");
5858    }
5859
5860    #[tokio::test]
5861    async fn banlist_newer_edition_applies_older_is_refused() {
5862        let (_tmp, _guard) = init_test_db();
5863        let relay = MemoryRelay::new();
5864        let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5865            .await
5866            .expect("create");
5867        let id_hex = community.id.to_hex();
5868        let banlist_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::banlist_locator(&community.id));
5869        let mallory = "aa".repeat(32);
5870        let bob = "bb".repeat(32);
5871
5872        // An owner-signed v1 banlist edition (banning Mallory) is injected on the relay WITHOUT touching
5873        // local state, so the local head stays at 0 and the first fetch must fold it from the relay.
5874        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5875        let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[mallory.clone()], 1, None, 1000, None).unwrap();
5876        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5877        relay.inject(&outer, &community.relays);
5878
5879        // Fetch folds the v1 edition, verifies the owner held BAN, applies it + advances the head.
5880        let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5881        assert_eq!(applied, vec![mallory.clone()]);
5882        let (head_v, _) = crate::db::community::get_edition_head(&id_hex, &banlist_entity).unwrap().unwrap();
5883        assert_eq!(head_v, 1, "banlist edition head advanced to v1");
5884
5885        // We now hold a NEWER local edition (v2, banning Mallory + Bob); the relay still carries only
5886        // v1 — a re-fetch must NOT roll us back to it (refuse-downgrade by edition version).
5887        crate::db::community::set_community_banlist(&id_hex, &[mallory.clone(), bob.clone()], 2).unwrap();
5888        crate::db::community::set_edition_head(&id_hex, &banlist_entity, 2, &[0x22u8; 32]).unwrap();
5889        let after = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5890        assert_eq!(after, vec![mallory, bob], "older relay edition refused, local banlist preserved");
5891    }
5892
5893    #[tokio::test]
5894    async fn unauthorized_banlist_edition_is_rejected() {
5895        // The keyless BAN-authority gate: a validly-signed banlist edition from a signer who holds no
5896        // BAN role (not the owner, never granted) is DROPPED on fetch — the inner signature proves
5897        // authorship, not authority. Authority is re-verified against the authorized roster.
5898        let (_tmp, _guard) = init_test_db();
5899        let relay = MemoryRelay::new();
5900        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5901        let bob = "bb".repeat(32);
5902
5903        // A random identity (no role) signs + injects a v1 banlist edition banning Bob.
5904        let mallory = Keys::generate();
5905        let inner = crate::community::roster::build_banlist_edition(&mallory, &community.id, &[bob], 1, None, 1000, None).unwrap();
5906        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5907        relay.inject(&outer, &community.relays);
5908
5909        // Fetch must reject it (signer not authorized) — the banlist stays empty.
5910        let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5911        assert!(applied.is_empty(), "an unauthorized signer's banlist edition is rejected");
5912    }
5913
5914    #[tokio::test]
5915    async fn banlist_receiver_enforces_per_target_outrank() {
5916        // The receive-side gate, not just the BAN bit: an Admin (holds BAN) who bans a PEER Admin
5917        // is rejected on fetch — equal can't act on equal. A bit-only check would fail open here.
5918        let (_tmp, _guard) = init_test_db();
5919        let relay = MemoryRelay::new();
5920        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5921        let cid = community.id.to_hex();
5922        let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5923        let alice = Keys::generate();
5924        let bob = Keys::generate();
5925        // Owner grants both Admin (so both sit at position 1, peers).
5926        set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()]).await.unwrap();
5927        set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5928
5929        // Alice (Admin) authors a banlist banning peer-admin Bob, citing her own (owner-granted) Admin
5930        // grant, injected on the relay.
5931        let cite = authority_citation(&community, &alice.public_key().to_hex());
5932        let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[bob.public_key().to_hex()], 1, None, 1000, cite.as_ref()).unwrap();
5933        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5934        relay.inject(&outer, &community.relays);
5935
5936        // Fetch must reject it — Alice doesn't strictly outrank her peer Bob.
5937        let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5938        assert!(applied.is_empty(), "an admin can't ban a peer admin (receiver-side outrank)");
5939    }
5940
5941    #[tokio::test]
5942    async fn banlist_admin_bans_regular_member_applies() {
5943        // The positive companion to the peer-rejection: an Admin (holds BAN) banning a REGULAR member
5944        // (no role, sits below) IS authorized on the receiver — Alice strictly outranks them.
5945        let (_tmp, _guard) = init_test_db();
5946        let relay = MemoryRelay::new();
5947        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5948        let cid = community.id.to_hex();
5949        let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5950        let alice = Keys::generate();
5951        set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5952
5953        let carol = "cc".repeat(32);
5954        // Alice cites her owner-granted Admin grant — the pinned authority a non-owner must carry.
5955        let cite = authority_citation(&community, &alice.public_key().to_hex());
5956        let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol.clone()], 1, None, 1000, cite.as_ref()).unwrap();
5957        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5958        relay.inject(&outer, &community.relays);
5959
5960        let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5961        assert_eq!(applied, vec![carol], "an admin's ban of a regular member applies");
5962    }
5963
5964    #[tokio::test]
5965    async fn owner_banlist_needs_no_citation() {
5966        // The owner is supreme and cites nothing — an owner-signed banlist edition with NO citation
5967        // applies. This is the `owner_hex == actor` bypass in `authority_citation_satisfied`.
5968        let (_tmp, _guard) = init_test_db();
5969        let relay = MemoryRelay::new();
5970        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5971        let victim = "cc".repeat(32);
5972
5973        // Owner hand-signs an uncited v1 banlist, injected on the relay (local head stays 0 → folds fresh).
5974        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5975        let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[victim.clone()], 1, None, 1000, None).unwrap();
5976        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5977        relay.inject(&outer, &community.relays);
5978
5979        let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5980        assert_eq!(applied, vec![victim], "an owner's uncited ban applies");
5981    }
5982
5983    #[tokio::test]
5984    async fn banlist_with_forged_citation_hash_is_rejected() {
5985        // fork guard: an authorized admin who cites her real grant entity + version but the WRONG
5986        // hash (a non-canonical fork at the tip) is rejected — the cited proof must be the one we folded.
5987        let (_tmp, _guard) = init_test_db();
5988        let relay = MemoryRelay::new();
5989        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5990        let cid = community.id.to_hex();
5991        let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5992        let alice = Keys::generate();
5993        set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5994
5995        let carol = "cc".repeat(32);
5996        // Real entity + version, but a fabricated hash → the cited edition isn't the one that won the fold.
5997        let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
5998        cite.edition_hash = [0xEE; 32];
5999        let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
6000        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6001        relay.inject(&outer, &community.relays);
6002
6003        let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6004        assert!(applied.is_empty(), "a forged-hash citation is rejected");
6005    }
6006
6007    #[tokio::test]
6008    async fn banlist_citing_unsynced_future_version_is_rejected() {
6009        // The completeness gate (fail closed): a genuinely-authorized admin who cites a FUTURE version of
6010        // her grant that nobody has (≥ what we folded) is rejected — we can't confirm authority at a
6011        // version we haven't synced. Isolates the sync-floor from the permission check (she IS an admin).
6012        let (_tmp, _guard) = init_test_db();
6013        let relay = MemoryRelay::new();
6014        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6015        let cid = community.id.to_hex();
6016        let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6017        let alice = Keys::generate();
6018        set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6019
6020        let carol = "cc".repeat(32);
6021        let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
6022        cite.version += 5; // cite a grant version that doesn't exist on any relay
6023        let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
6024        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6025        relay.inject(&outer, &community.relays);
6026
6027        let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6028        assert!(applied.is_empty(), "citing an unsynced future grant version fails closed");
6029    }
6030
6031    #[tokio::test]
6032    async fn demoted_banner_superseded_ban_is_rejected() {
6033        // Refuse-superseded: an admin bans (citing her v1 grant), then the owner revokes her admin role.
6034        // Her citation is still SATISFIED (we hold a later v2 head of her grant), but the current
6035        // authorized roster no longer ranks her → the per-target outrank fails → the stale ban is dropped.
6036        let (_tmp, _guard) = init_test_db();
6037        let relay = MemoryRelay::new();
6038        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6039        let cid = community.id.to_hex();
6040        let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6041        let alice = Keys::generate();
6042        set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6043
6044        let carol = "cc".repeat(32);
6045        // Alice bans Carol while she IS an admin, citing her v1 grant.
6046        let cite = authority_citation(&community, &alice.public_key().to_hex());
6047        let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
6048        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6049        relay.inject(&outer, &community.relays);
6050
6051        // Owner revokes Alice's admin (publishes her v2 empty grant).
6052        set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![]).await.unwrap();
6053
6054        let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6055        assert!(applied.is_empty(), "a since-demoted banner's stale ban is rejected (refuse-superseded)");
6056    }
6057
6058    #[tokio::test]
6059    async fn withheld_revocation_cannot_resurrect_a_demoted_banners_grant() {
6060        // The refuse-downgrade FLOOR: we have already synced Alice's revocation (her grant head is
6061        // at v2 locally), but a hostile relay serves only her OLD v1 admin grant + her stale ban,
6062        // withholding v2. The fold seeds Alice's grant from the held v2 floor, so the below-floor v1 is
6063        // refused — her grant never re-materializes, and the stale ban is dropped. (Without the floor,
6064        // the fold would roll back to v1 and re-authorize her: the H1 fail-open this closes.)
6065        let (_tmp, _guard) = init_test_db();
6066        let relay = MemoryRelay::new();
6067        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6068        let cid = community.id.to_hex();
6069        let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6070        let alice = Keys::generate();
6071        set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6072
6073        // Alice (admin at v1) bans Carol, citing her v1 grant — only this + her v1 grant reach the relay.
6074        let carol = "cc".repeat(32);
6075        let cite = authority_citation(&community, &alice.public_key().to_hex());
6076        let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
6077        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6078        relay.inject(&outer, &community.relays);
6079
6080        // We've SEEN the revocation (head floor for Alice's grant advanced to v2 locally) but the relay
6081        // withholds the v2 edition itself.
6082        let alice_bytes = alice.public_key().to_bytes();
6083        let grant_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::grant_locator(&community.id, &alice_bytes));
6084        crate::db::community::set_edition_head(&cid, &grant_entity, 2, &[0xAB; 32]).unwrap();
6085
6086        let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6087        assert!(applied.is_empty(), "a withheld revocation can't roll the banner's grant back to re-authorize them");
6088    }
6089
6090    #[tokio::test]
6091    async fn invite_registry_round_trips_and_drives_is_public() {
6092        // computed mode: a fresh community is Private (empty registry); a peer folds the owner's
6093        // registry edition purely from the relay and computes Public; clearing the registry (revoke the
6094        // last link) flips it back to Private — the privatize precondition.
6095        let (_tmp, _guard) = init_test_db();
6096        let relay = MemoryRelay::new();
6097        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6098        assert!(!is_public(&community).unwrap(), "a fresh community is Private");
6099
6100        // Owner's per-creator link edition v1 injected on the relay (no local head yet → folds fresh,
6101        // like a peer). The owner holds CREATE_INVITE (ADMIN_ALL), so the fold authorizes + unions it.
6102        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6103        let loc = "1a".repeat(32);
6104        let inner = crate::community::roster::build_invite_links_edition(&owner, &community.id, &[loc.clone()], 1, None, 1000, None).unwrap();
6105        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6106        relay.inject(&outer, &community.relays);
6107
6108        let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
6109        assert_eq!(applied, vec![loc], "the owner's link edition folds + unions from the relay");
6110        assert!(is_public(&community).unwrap(), "mode recomputed Public from the folded aggregate");
6111
6112        // The owner retires their links (newer v2, empty) → aggregate empties → Private.
6113        publish_my_invite_links(&relay, &community, &[]).await.unwrap();
6114        let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
6115        assert!(applied.is_empty() && !is_public(&community).unwrap(), "an empty aggregate is Private");
6116    }
6117
6118    #[tokio::test]
6119    async fn metadata_edit_round_trips_to_a_lagging_member() {
6120        // metadata fold: a member holding only the genesis v1 folds the owner's GroupRoot v2 from the
6121        // relay and applies the display edit. (Edition built + injected directly so the local head stays
6122        // at v1 — `set_edition_head` is monotonic, so a republish would advance it and defeat the test.)
6123        let (_tmp, _guard) = init_test_db();
6124        let relay = MemoryRelay::new();
6125        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6126        let cid = community.id.to_hex();
6127        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6128        let (genesis_v, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6129        assert_eq!(genesis_v, 1);
6130
6131        let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
6132        edited.name = "Renamed HQ".into();
6133        edited.description = Some("now with a topic".into());
6134        let inner = crate::community::roster::build_community_root_edition(&owner, &community.id, &edited, 2, Some(&genesis_hash), 4000, None).unwrap();
6135        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6136        relay.inject(&outer, &community.relays);
6137
6138        fetch_and_apply_metadata(&relay, &community).await.unwrap();
6139        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6140        assert_eq!(after.name, "Renamed HQ", "the owner's GroupRoot edit folded from the relay");
6141        assert_eq!(after.description.as_deref(), Some("now with a topic"));
6142        assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "head advanced to v2");
6143    }
6144
6145    #[tokio::test]
6146    async fn unauthorized_metadata_edit_is_ignored() {
6147        // A signer WITHOUT manage-metadata authority can't move the community's display, even with a
6148        // perfectly-chained, validly-signed GroupRoot edition (the author gate, not just the chain).
6149        let (_tmp, _guard) = init_test_db();
6150        let relay = MemoryRelay::new();
6151        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6152        let cid = community.id.to_hex();
6153        let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6154
6155        let mallory = Keys::generate();
6156        let mut hacked = crate::community::metadata::CommunityMetadata::of(&community);
6157        hacked.name = "Pwned".into();
6158        let inner = crate::community::roster::build_community_root_edition(&mallory, &community.id, &hacked, 2, Some(&genesis_hash), 5000, None).unwrap();
6159        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6160        relay.inject(&outer, &community.relays);
6161
6162        fetch_and_apply_metadata(&relay, &community).await.unwrap();
6163        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6164        assert_eq!(after.name, "HQ", "a non-manage-metadata signer's GroupRoot edit is rejected");
6165        assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 1, "an unauthorized edit never advances the head");
6166    }
6167
6168    #[tokio::test]
6169    async fn channel_rename_round_trips_from_owner_edition() {
6170        // The vsk=2 ChannelMetadata fold: an owner-signed channel rename (v2, chained off genesis) folds
6171        // and applies to the matching channel.
6172        let (_tmp, _guard) = init_test_db();
6173        let relay = MemoryRelay::new();
6174        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6175        let cid = community.id.to_hex();
6176        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6177        let channel = community.channels[0].clone();
6178        let ch_hex = channel.id.to_hex();
6179        let (_, genesis_ch_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6180
6181        let meta = crate::community::metadata::ChannelMetadata { name: "announcements".into() };
6182        let inner = crate::community::roster::build_channel_metadata_edition(&owner, &channel.id, &meta, 2, Some(&genesis_ch_hash), 6000, None).unwrap();
6183        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6184        relay.inject(&outer, &community.relays);
6185
6186        fetch_and_apply_metadata(&relay, &community).await.unwrap();
6187        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6188        assert_eq!(after.channels[0].name, "announcements", "the owner's channel rename folded + applied");
6189        assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced to v2");
6190    }
6191
6192    /// Build a v2 GroupRoot edition by `author` (name/created over genesis) → (sealed outer, self_hash,
6193    /// inner_id). Two of these with different (name, created) form a same-version concurrent fork.
6194    fn root_fork_v2(author: &Keys, community: &Community, name: &str, created: u64, genesis_hash: &[u8; 32]) -> (Event, [u8; 32], [u8; 32]) {
6195        let mut meta = crate::community::metadata::CommunityMetadata::of(community);
6196        meta.name = name.into();
6197        let inner = crate::community::roster::build_community_root_edition(author, &community.id, &meta, 2, Some(genesis_hash), created, None).unwrap();
6198        let self_hash = crate::community::version::edition_hash(&community.id.0, 2, Some(genesis_hash), inner.content.as_bytes());
6199        let inner_id = inner.id.to_bytes();
6200        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6201        (outer, self_hash, inner_id)
6202    }
6203
6204    /// A concurrent v2 ChannelMetadata fork (the channel analogue of [`root_fork_v2`]): a v2 rename chained
6205    /// off the channel's genesis, returned as (sealed outer, self_hash, inner_id).
6206    fn channel_fork_v2(author: &Keys, community: &Community, channel_id: &crate::community::ChannelId, name: &str, created: u64, genesis_hash: &[u8; 32]) -> (Event, [u8; 32], [u8; 32]) {
6207        let meta = crate::community::metadata::ChannelMetadata { name: name.into() };
6208        let inner = crate::community::roster::build_channel_metadata_edition(author, channel_id, &meta, 2, Some(genesis_hash), created, None).unwrap();
6209        let self_hash = crate::community::version::edition_hash(&channel_id.0, 2, Some(genesis_hash), inner.content.as_bytes());
6210        let inner_id = inner.id.to_bytes();
6211        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6212        (outer, self_hash, inner_id)
6213    }
6214
6215    /// W1 — channel metadata converges on a same-version fork exactly like GroupRoot: two authorized editors
6216    /// rename a channel concurrently (both v2, different content); a client holding the LOSER (higher inner
6217    /// id) converges onto the deterministic winner (lower inner id) instead of clinging to its own.
6218    #[tokio::test]
6219    async fn channel_same_version_fork_converges_to_the_lower_inner_id() {
6220        let (_tmp, _guard) = init_test_db();
6221        let relay = MemoryRelay::new();
6222        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6223        let cid = community.id.to_hex();
6224        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6225        let channel_id = community.channels[0].id;
6226        let ch_hex = channel_id.to_hex();
6227        let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6228
6229        let (out_a, ha, ida) = channel_fork_v2(&owner, &community, &channel_id, "alpha", 1000, &genesis_hash);
6230        let (out_b, hb, idb) = channel_fork_v2(&owner, &community, &channel_id, "bravo", 2000, &genesis_hash);
6231        let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
6232            ("alpha", ha, ida, "bravo", hb, idb)
6233        } else {
6234            ("bravo", hb, idb, "alpha", ha, ida)
6235        };
6236        // Hold the LOSER locally (head + channel name), then see both forks.
6237        crate::db::community::set_edition_head_with_id(&cid, &ch_hex, 2, &lose_h, &lose_id).unwrap();
6238        {
6239            let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6240            c.channels.iter_mut().find(|ch| ch.id == channel_id).unwrap().name = lose_name.into();
6241            crate::db::community::save_community(&c).unwrap();
6242        }
6243        relay.inject(&out_a, &community.relays);
6244        relay.inject(&out_b, &community.relays);
6245
6246        fetch_and_apply_metadata(&relay, &community).await.unwrap();
6247        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6248        let ch_name = &after.channels.iter().find(|c| c.id == channel_id).unwrap().name;
6249        assert_eq!(ch_name, win_name, "channel converged on the lower-inner-id winner, not our held fork");
6250        assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap(), (2, win_h), "channel head self_hash converged at the SAME version");
6251        assert_eq!(crate::db::community::get_edition_head_inner_id(&cid, &ch_hex).unwrap(), Some(win_id), "channel head inner_id moved to the winner");
6252
6253        // Flip-flop-proof: a second pass holding the winner keeps it.
6254        fetch_and_apply_metadata(&relay, &community).await.unwrap();
6255        let after2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6256        assert_eq!(&after2.channels.iter().find(|c| c.id == channel_id).unwrap().name, win_name, "no flip back to the higher-id fork");
6257    }
6258
6259    /// W1 — channel authority gate runs BEFORE the tiebreak: a demoted/unauthorized author's same-version
6260    /// channel rename loses even with the lowest inner id; the authorized rename is applied.
6261    #[tokio::test]
6262    async fn channel_same_version_fork_excludes_an_unauthorized_lower_id_edition() {
6263        let (_tmp, _guard) = init_test_db();
6264        let relay = MemoryRelay::new();
6265        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6266        let cid = community.id.to_hex();
6267        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6268        let channel_id = community.channels[0].id;
6269        let ch_hex = channel_id.to_hex();
6270        let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6271
6272        let (owner_out, owner_h, owner_id) = channel_fork_v2(&owner, &community, &channel_id, "legit", 1000, &genesis_hash);
6273        // Grind mallory's created_at until her forgery sorts FIRST author-blind (lower inner id).
6274        let mallory = Keys::generate();
6275        let mal_out = {
6276            let mut chosen = None;
6277            for t in 1..=10_000u64 {
6278                let cand = channel_fork_v2(&mallory, &community, &channel_id, "forged", t, &genesis_hash);
6279                if cand.2 < owner_id { chosen = Some(cand.0); break; }
6280            }
6281            chosen.expect("a mallory channel edition with a lower inner id")
6282        };
6283        relay.inject(&owner_out, &community.relays);
6284        relay.inject(&mal_out, &community.relays);
6285
6286        fetch_and_apply_metadata(&relay, &community).await.unwrap();
6287        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6288        assert_eq!(&after.channels.iter().find(|c| c.id == channel_id).unwrap().name, &"legit".to_string(), "the channel forgery never wins despite a lower inner id");
6289        assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap(), (2, owner_h), "the authorized channel edition is the head");
6290    }
6291
6292    /// epoch-primary floor: a re-founding re-genesises every entity to v1 under the NEW epoch, and that
6293    /// v1 must supersede the held high version (else compaction is impossible) — WITHOUT weakening in-epoch
6294    /// refuse-downgrade. Exercises the `set_edition_head` write guard directly.
6295    #[tokio::test]
6296    async fn epoch_primary_floor_lets_a_refounding_v1_supersede_a_held_high_version() {
6297        let (_tmp, _guard) = init_test_db();
6298        let relay = MemoryRelay::new();
6299        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6300        let cid = community.id.to_hex();
6301        // Drive the GroupRoot head to v5 within epoch 0.
6302        for v in 2..=5u64 {
6303            crate::db::community::set_edition_head_with_id(&cid, &cid, v, &[v as u8; 32], &[v as u8; 32]).unwrap();
6304        }
6305        assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5);
6306        // In-epoch refuse-downgrade still holds: a lower version is a no-op.
6307        crate::db::community::set_edition_head_with_id(&cid, &cid, 3, &[0x33; 32], &[0x33; 32]).unwrap();
6308        assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5, "in-epoch downgrade refused");
6309
6310        // Re-found: bump the community to epoch 1, then write the compacted GroupRoot genesis (v1 @ epoch 1).
6311        crate::db::community::advance_server_root_epoch(&cid, 1, &[0xEE; 32]).unwrap();
6312        crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0x01; 32], &[0x01; 32]).unwrap();
6313        let (v, h) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6314        assert_eq!((v, h), (1, [0x01; 32]), "epoch-1 v1 supersedes epoch-0 v5 (epoch-primary)");
6315        assert_eq!(
6316            crate::db::community::get_all_edition_heads_epoched(&cid).unwrap().get(&cid).map(|(e, v, _)| (*e, *v)),
6317            Some((1, 1)),
6318            "head now recorded at epoch 1",
6319        );
6320        // And within the NEW epoch, refuse-downgrade resumes: v1 holds, a re-presented v1 stays.
6321        crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0xAA; 32], &[0x02; 32]).unwrap();
6322        assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().1, [0x01; 32], "same-epoch same-version is not an advance");
6323    }
6324
6325    /// T1 — Concord Convergence: two authorized editors edit from the same base, both produce v2 with
6326    /// different content. A client holding the LOSER (higher inner id) converges IN PLACE onto the
6327    /// deterministic winner (lower inner id) instead of clinging to its own — the live divergence bug.
6328    #[tokio::test]
6329    async fn same_version_fork_converges_to_the_lower_inner_id() {
6330        let (_tmp, _guard) = init_test_db();
6331        let relay = MemoryRelay::new();
6332        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6333        let cid = community.id.to_hex();
6334        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6335        let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6336
6337        let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
6338        let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
6339        // Winner = lower inner id; we hold the loser.
6340        let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
6341            ("Alpha", ha, ida, "Bravo", hb, idb)
6342        } else {
6343            ("Bravo", hb, idb, "Alpha", ha, ida)
6344        };
6345        crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
6346        {
6347            let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6348            c.name = lose_name.into();
6349            crate::db::community::save_community(&c).unwrap();
6350        }
6351        relay.inject(&out_a, &community.relays);
6352        relay.inject(&out_b, &community.relays);
6353
6354        fetch_and_apply_metadata(&relay, &community).await.unwrap();
6355        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6356        assert_eq!(after.name, win_name, "converged on the lower-inner-id winner, not our own held fork");
6357        assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, win_h), "head self_hash converged at the SAME version");
6358        assert_eq!(crate::db::community::get_edition_head_inner_id(&cid, &cid).unwrap(), Some(win_id), "head inner_id moved to the winner");
6359
6360        // Flip-flop-proof: holding the winner, a second pass seeing both forks keeps the winner.
6361        fetch_and_apply_metadata(&relay, &community).await.unwrap();
6362        assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().name, win_name, "no flip back to the higher-id fork");
6363    }
6364
6365    /// T2 — the converged head feeds the next edit: v3 chains prev_hash from the CONVERGED winner, so a
6366    /// fresh fold reaches v3 contiguously (no re-fork). Guards the silent same-version no-op trap (B2/B5).
6367    #[tokio::test]
6368    async fn converged_head_chains_the_next_edit_without_reforking() {
6369        let (_tmp, _guard) = init_test_db();
6370        let relay = MemoryRelay::new();
6371        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6372        let cid = community.id.to_hex();
6373        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6374        let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6375
6376        let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
6377        let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
6378        let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
6379        crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
6380        relay.inject(&out_a, &community.relays);
6381        relay.inject(&out_b, &community.relays);
6382        fetch_and_apply_metadata(&relay, &community).await.unwrap();
6383        assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "converged at v2");
6384
6385        let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6386        c.name = "Third".into();
6387        republish_community_metadata(&relay, &c).await.unwrap();
6388        assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 3, "advanced to v3 off the converged head");
6389
6390        let empty: std::collections::HashMap<String, (u64, [u8; 32])> = std::collections::HashMap::new();
6391        let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &empty);
6392        assert_eq!(folded.root_head.as_ref().map(|h| h.version), Some(3), "a fresh fold reaches v3");
6393        assert!(!folded.gapped_entities.contains(&community.id.0), "the chain is contiguous genesis -> winner -> v3");
6394    }
6395
6396    /// T3 — authority gate runs BEFORE the tiebreak: an UNAUTHORIZED same-version edition loses even with
6397    /// the lowest inner id. We apply the authorized edition, never the forgery that sorts first.
6398    #[tokio::test]
6399    async fn same_version_fork_excludes_an_unauthorized_lower_id_edition() {
6400        let (_tmp, _guard) = init_test_db();
6401        let relay = MemoryRelay::new();
6402        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6403        let cid = community.id.to_hex();
6404        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6405        let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6406
6407        let (owner_out, owner_h, owner_id) = root_fork_v2(&owner, &community, "Legit", 1000, &genesis_hash);
6408        // Grind mallory's created_at until her forgery sorts FIRST author-blind (lower inner id).
6409        let mallory = Keys::generate();
6410        let (mal_out, mal_id) = {
6411            let mut chosen = None;
6412            for t in 1..=10_000u64 {
6413                let cand = root_fork_v2(&mallory, &community, "Forged", t, &genesis_hash);
6414                if cand.2 < owner_id { chosen = Some((cand.0, cand.2)); break; }
6415            }
6416            chosen.expect("a mallory edition with a lower inner id")
6417        };
6418        assert!(mal_id < owner_id, "premise: the forgery sorts first author-blind");
6419        relay.inject(&owner_out, &community.relays);
6420        relay.inject(&mal_out, &community.relays);
6421
6422        // Floor stays at genesis v1, so the consumer must CHOOSE among the v2 candidates.
6423        fetch_and_apply_metadata(&relay, &community).await.unwrap();
6424        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6425        assert_eq!(after.name, "Legit", "the forgery never wins despite a lower inner id");
6426        assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, owner_h), "the authorized edition is the head");
6427    }
6428
6429    /// T4 — the convergence exemption is DISPLAY-ONLY: a same-version fork on an authority record
6430    /// (banlist) where we hold the higher-id edition stays QUARANTINED (gapped, not folded). Converging
6431    /// authority off a withheld view would be a relay-choosable censorship lever, so it fails closed.
6432    #[tokio::test]
6433    async fn same_version_fork_on_an_authority_record_fails_closed() {
6434        let (_tmp, _guard) = init_test_db();
6435        let relay = MemoryRelay::new();
6436        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6437        let cid = community.id.to_hex();
6438        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6439        let bl_eid = crate::community::derive::banlist_locator(&community.id);
6440        let bl_hex = crate::simd::hex::bytes_to_hex_32(&bl_eid);
6441
6442        let prev = [0x99u8; 32]; // both v2 forks cite the same (held) v1; the ==floor anchor checks self_hash
6443        let build_ban = |list: &[String], created: u64| {
6444            let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, list, 2, Some(&prev), created, None).unwrap();
6445            let self_hash = crate::community::version::edition_hash(&bl_eid, 2, Some(&prev), inner.content.as_bytes());
6446            let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6447            (outer, self_hash, inner.id.to_bytes())
6448        };
6449        let (out_a, ha, ida) = build_ban(&["aa".repeat(32)], 1000);
6450        let (out_b, hb, idb) = build_ban(&["bb".repeat(32)], 2000);
6451        // Hold the higher-id edition → the fold's winner (lower id) differs → adopting it would require a
6452        // same-version swap, which an authority record must REFUSE.
6453        let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
6454        crate::db::community::set_edition_head_with_id(&cid, &bl_hex, 2, &lose_h, &lose_id).unwrap();
6455        relay.inject(&out_a, &community.relays);
6456        relay.inject(&out_b, &community.relays);
6457
6458        let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
6459        let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &floors);
6460        assert!(folded.gapped_entities.contains(&bl_eid), "the authority-record fork is quarantined");
6461        assert!(folded.banlist_head.is_none() && folded.banlist_author.is_none(), "no banlist folded off the withheld view");
6462    }
6463
6464    #[tokio::test]
6465    async fn editions_sign_through_the_active_client_signer() {
6466        // The bunker code path: with a NOSTR_CLIENT signer installed, authority editions sign through
6467        // `client.signer()` (the same route a NIP-46 bunker takes) rather than the local-vault fallback.
6468        // Proven end-to-end: create a community while the client signer is active, then fold + authorize
6469        // its genesis (the owner attestation must verify → the editions were signed by the right identity).
6470        let (_tmp, _guard) = init_test_db();
6471        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6472        crate::state::set_nostr_client(nostr_sdk::prelude::Client::builder().build());
6473
6474        let relay = MemoryRelay::new();
6475        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6476        let cid = community.id.to_hex();
6477
6478        // A banlist edition (a non-genesis authority action) also signs via the client path + folds.
6479        publish_banlist(&relay, &community, &["dd".repeat(32)]).await.unwrap();
6480        let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
6481        let folded = crate::community::roster::fold_roster(
6482            &fetch_control_inners(&relay, &community).await, &community.id, &floors);
6483        assert_eq!(folded.banlist_author, Some(owner.public_key()), "banlist signed by the client signer");
6484        assert!(folded.root_author.is_some(), "genesis GroupRoot folded");
6485        let _ = crate::state::take_nostr_client();
6486    }
6487
6488    /// Fetch + open the control-plane inner editions for a community (epoch 0) — test helper.
6489    async fn fetch_control_inners(relay: &MemoryRelay, community: &Community) -> Vec<Event> {
6490        let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
6491        let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
6492        let mut out = Vec::new();
6493        for ev in relay.fetch(&query, &community.relays).await.unwrap() {
6494            if let Ok(inner) = crate::community::roster::open_control_edition(&ev, &community.server_root_key) {
6495                out.push(inner);
6496            }
6497        }
6498        out
6499    }
6500
6501    /// Drop the local secret key while keeping a client signer + the public key — the test shape of a
6502    /// NIP-46 bunker account (signs remotely, no raw local key for ECDH rekeys).
6503    fn simulate_bunker(owner: &Keys) {
6504        crate::state::set_nostr_client(nostr_sdk::prelude::Client::builder().build());
6505        // The identity stays signable (as a bunker would) while the local vault
6506        // goes empty, so any path that insists on a local key still fails.
6507        crate::signer::set_test_signer(Some(crate::signer::ActiveSigner::Keys(owner.clone())));
6508        crate::state::MY_SECRET_KEY.clear(&[]);
6509        assert!(crate::state::MY_SECRET_KEY.to_keys().is_none(), "bunker sim: no local key");
6510    }
6511
6512    #[tokio::test]
6513    async fn am_i_banned_detects_own_npub_in_banlist() {
6514        // The ban self-remove signal: `am_i_banned` is true iff the local npub is in the folded banlist.
6515        let (_tmp, _guard) = init_test_db();
6516        let relay = MemoryRelay::new();
6517        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6518        let me = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
6519        let cid = community.id.to_hex();
6520        assert!(!am_i_banned(&community), "not banned on a fresh community");
6521        // Inject ourselves into the cached banlist (the fold would do this from a real edition).
6522        crate::db::community::set_community_banlist(&cid, &[me], 1).unwrap();
6523        assert!(am_i_banned(&community), "own npub in the banlist → banned → self-remove");
6524        crate::db::community::set_community_banlist(&cid, &[], 2).unwrap();
6525        assert!(!am_i_banned(&community), "cleared banlist → not banned");
6526    }
6527
6528    #[tokio::test]
6529    async fn bunker_owner_cannot_ban_in_private_community() {
6530        // Fail-fast: a private-community ban needs a read-cut rekey, which a bunker account can't do. It
6531        // must refuse BEFORE publishing — no "banned but still readable" half-state.
6532        let (_tmp, _guard) = init_test_db();
6533        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6534        let relay = MemoryRelay::new();
6535        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6536        simulate_bunker(&owner);
6537
6538        let victim = "cc".repeat(32);
6539        let err = publish_banlist(&relay, &community, &[victim]).await.unwrap_err();
6540        assert!(err.contains("private community") && err.contains("bunker"), "clear bunker explanation: {err}");
6541        assert!(
6542            crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap().is_empty(),
6543            "the ban must NOT half-apply (nothing published or persisted)"
6544        );
6545        let _ = crate::state::take_nostr_client();
6546    }
6547
6548    #[tokio::test]
6549    async fn bunker_owner_can_ban_in_public_community() {
6550        // A PUBLIC ban doesn't rekey (anti-memberlist), so a bunker account CAN ban — the guard must not
6551        // over-block. (Mint a link → Public, then ban as a bunker.)
6552        let (_tmp, _guard) = init_test_db();
6553        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6554        let relay = MemoryRelay::new();
6555        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6556        create_public_invite(&relay, &community, None, None).await.unwrap();
6557        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6558        assert!(is_public(&community).unwrap(), "minting a link made it Public");
6559        simulate_bunker(&owner);
6560
6561        let victim = "cc".repeat(32);
6562        publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
6563        assert_eq!(
6564            crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap(),
6565            vec![victim],
6566            "a public ban from a bunker account succeeds (no rekey needed)"
6567        );
6568        let _ = crate::state::take_nostr_client();
6569    }
6570
6571    #[tokio::test]
6572    async fn bunker_owner_cannot_privatize() {
6573        // Fail-fast: revoking the LAST link privatizes → re-founding rekey, which a bunker can't do. Must
6574        // refuse before publishing, leaving the community Public (no half-apply).
6575        let (_tmp, _guard) = init_test_db();
6576        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6577        let relay = MemoryRelay::new();
6578        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6579        let (token, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6580        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6581        simulate_bunker(&owner);
6582
6583        let err = revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token)).await.unwrap_err();
6584        assert!(err.contains("private") && err.contains("bunker"), "clear bunker explanation: {err}");
6585        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6586        assert!(is_public(&after).unwrap(), "the revoke must NOT half-apply — community stays Public");
6587        let _ = crate::state::take_nostr_client();
6588    }
6589
6590    #[tokio::test]
6591    async fn non_owner_admin_can_edit_community_metadata() {
6592        // The "no hardcoding" crux: a NON-OWNER member granted the Admin role (which carries
6593        // MANAGE_METADATA) can move the community's display, verified purely by the folded roster — not a
6594        // hardcoded owner check.
6595        let (_tmp, _guard) = init_test_db();
6596        let relay = MemoryRelay::new();
6597        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6598        let cid = community.id.to_hex();
6599        let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6600
6601        // Owner grants `admin` the Admin role (publishes the grant edition to the relay).
6602        let admin = Keys::generate();
6603        let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6604        set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6605
6606        // `admin` (NOT the owner) publishes a GroupRoot v2 renaming the community.
6607        let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
6608        edited.name = "Admin Renamed".into();
6609        let inner = crate::community::roster::build_community_root_edition(&admin, &community.id, &edited, 2, Some(&genesis_hash), 7000, None).unwrap();
6610        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6611        relay.inject(&outer, &community.relays);
6612
6613        fetch_and_apply_metadata(&relay, &community).await.unwrap();
6614        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6615        assert_eq!(after.name, "Admin Renamed", "a MANAGE_METADATA admin (not the owner) can edit metadata");
6616    }
6617
6618    #[tokio::test]
6619    async fn banning_an_admin_revokes_their_role() {
6620        // Removal strips authority: a banned admin's grant must NOT dangle — else unban silently restores
6621        // admin and the roster keeps listing a non-member as admin. Public community isolates the role-strip
6622        // from the read-cut.
6623        let (_tmp, _guard) = init_test_db();
6624        let relay = MemoryRelay::new();
6625        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6626        let cid = community.id.to_hex();
6627        create_public_invite(&relay, &community, None, None).await.unwrap();
6628        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6629
6630        let alice = Keys::generate();
6631        let alice_hex = alice.public_key().to_hex();
6632        let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6633        set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
6634        let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
6635            .grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
6636        assert!(holds_role(&alice_hex), "alice is admin pre-ban");
6637
6638        publish_banlist(&relay, &community, &[alice_hex.clone()]).await.unwrap();
6639        assert!(!holds_role(&alice_hex), "banning an admin revokes their role — no dangling grant");
6640    }
6641
6642    #[tokio::test]
6643    async fn kicking_an_admin_revokes_their_role() {
6644        // Same removal-strips-authority rule for the soft tier: a kicked admin who rejoins (fresh invite)
6645        // must NOT be silently still-admin.
6646        let (_tmp, _guard) = init_test_db();
6647        let relay = MemoryRelay::new();
6648        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6649        let cid = community.id.to_hex();
6650        let alice = Keys::generate();
6651        let alice_hex = alice.public_key().to_hex();
6652        let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6653        set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
6654        let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
6655            .grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
6656        assert!(holds_role(&alice_hex), "alice is admin pre-kick");
6657
6658        publish_kick(&relay, &community, &community.channels[0], &alice_hex).await.unwrap();
6659        assert!(!holds_role(&alice_hex), "kicking an admin revokes their role");
6660    }
6661
6662    #[tokio::test]
6663    async fn republish_channel_metadata_renames_and_publishes() {
6664        // The producer (the write side the consumer test was missing): renaming via
6665        // `republish_channel_metadata` updates the local channel AND advances the channel head.
6666        let (_tmp, _guard) = init_test_db();
6667        let relay = MemoryRelay::new();
6668        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6669        let cid = community.id.to_hex();
6670        let channel = community.channels[0].clone();
6671        let ch_hex = channel.id.to_hex();
6672
6673        republish_channel_metadata(&relay, &community, &channel.id, "lobby").await.unwrap();
6674        let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6675        assert_eq!(after.channels[0].name, "lobby", "the producer renamed the channel locally");
6676        assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced");
6677    }
6678
6679    #[tokio::test]
6680    async fn revoking_the_last_link_privatizes_and_rotates_the_base() {
6681        // The privatize trigger: minting links flips the computed mode to Public WITHOUT rotating;
6682        // revoking a non-last link stays Public, no rotation; revoking the LAST link flips to Private AND
6683        // re-founds the community (rotate the base/server-root to the observed participants → epoch bump),
6684        // sealing out link-joined lurkers.
6685        let (_tmp, _guard) = init_test_db();
6686        let relay = MemoryRelay::new();
6687        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6688        assert!(!is_public(&community).unwrap(), "a fresh community is Private");
6689        assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
6690
6691        // Mint two links → Public, base NOT rotated.
6692        let (t1, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6693        let (t2, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6694        let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6695        assert!(is_public(&c).unwrap(), "minting a link flips the mode to Public");
6696        assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "minting links does NOT rotate the base");
6697
6698        // Revoke the first of two → one link remains → still Public, still no rotation.
6699        revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t1)).await.unwrap();
6700        let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6701        assert!(is_public(&c).unwrap(), "one link remains → still Public");
6702        assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "revoking a non-last link does NOT rotate");
6703
6704        // Revoke the LAST link → Private + base rotated (re-founding).
6705        revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
6706        let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6707        assert!(!is_public(&c).unwrap(), "revoking the last link flips to Private");
6708        assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "privatize re-founded: the base key rotated");
6709
6710        // Idempotency: re-revoking the already-gone token must NOT re-found again (no second epoch
6711        // bump) — privatize fires only on a genuine Public→Private transition (`had_links`).
6712        revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
6713        let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6714        assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a no-op re-revoke does not double-rotate");
6715    }
6716
6717    #[tokio::test]
6718    async fn private_ban_reseals_base_public_ban_does_not() {
6719        // rekey-on-removal: banning in a PRIVATE community re-seals the base (epoch bump → the banned
6720        // member's read access is cut); in a PUBLIC community the base is NOT rotated (anti-memberlist).
6721        let (_tmp, _guard) = init_test_db();
6722        let relay = MemoryRelay::new();
6723        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6724        let victim = "cc".repeat(32);
6725
6726        // PRIVATE (no links) → banning rotates the base.
6727        assert!(!is_public(&community).unwrap(), "fresh community is Private");
6728        publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
6729        let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6730        assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a private-community ban re-seals the base");
6731
6732        // Go PUBLIC (mint a link), then ban another member → the base must NOT rotate again.
6733        create_public_invite(&relay, &c, None, None).await.unwrap();
6734        let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6735        assert!(is_public(&c).unwrap(), "minted a link → Public");
6736        publish_banlist(&relay, &c, &[victim.clone(), "dd".repeat(32)]).await.unwrap();
6737        let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6738        assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "a public-community ban does NOT rotate the base");
6739    }
6740
6741    #[tokio::test]
6742    async fn private_ban_seals_the_banned_member_out_of_the_new_root() {
6743        // rekey-on-removal SECURITY crux: a banned member must be EXCLUDED from the re-seal
6744        // recipient set so they CANNOT recover the new root — read access actually cut, not just epoch
6745        // bumped. Exercises the banlist(hex)→activity(bech32) reconciliation AND the persist-before-reseal
6746        // ordering end-to-end. The existing ban tests assert the epoch bump but never that the victim is
6747        // sealed out — this is the assertion that matters.
6748        use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
6749        use crate::community::rekey::{open_rekey_event, rekey_pairwise_secret};
6750        use crate::types::Message;
6751        use nostr_sdk::prelude::ToBech32;
6752        let (_tmp, _guard) = init_test_db();
6753        let relay = MemoryRelay::new();
6754        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6755        let cid = community.id.to_hex();
6756        let genesis_root = *community.server_root_key.as_bytes();
6757        let channel_hex = community.channels[0].id.to_hex();
6758
6759        // The victim posts → observed participant (absent the ban, they'd BE a re-seal recipient).
6760        let victim = Keys::generate();
6761        let victim_b32 = victim.public_key().to_bech32().unwrap();
6762        let mut m = Message::default();
6763        m.id = "aa".repeat(32);
6764        m.npub = Some(victim_b32.clone());
6765        m.at = 1000;
6766        crate::db::events::save_message(&channel_hex, &m).await.unwrap();
6767        assert!(
6768            crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
6769            "victim is observed before the ban"
6770        );
6771
6772        // Ban the victim (private community) → re-seal at epoch 1.
6773        publish_banlist(&relay, &community, &[victim.public_key().to_hex()]).await.unwrap();
6774        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
6775        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "private ban re-seals the base");
6776        assert!(
6777            !crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
6778            "the banned victim is no longer observed (banlist hex → bech32 reconciliation worked)"
6779        );
6780
6781        // The base rekey at epoch 1 must carry NO blob for the victim → they can't recover the new root.
6782        let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
6783        let found = relay
6784            .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
6785            .await
6786            .unwrap();
6787        assert_eq!(found.len(), 1, "the base rekey is published");
6788        let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
6789        let secret = rekey_pairwise_secret(victim.secret_key(), &parsed.rotator).unwrap();
6790        let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
6791        assert!(
6792            parsed.blobs.iter().all(|b| b.locator != loc),
6793            "the BANNED victim has NO blob — sealed OUT of the new root (read access is actually cut)"
6794        );
6795    }
6796
6797    /// A relay that simulates an account swap MID-PUBLISH: it bumps the session generation inside
6798    /// publish/publish_durable, so a `std::sync::Arc<crate::db::Session>` captured before the call is invalid by the time the
6799    /// caller re-checks after the await. The actual store delegates to an inner MemoryRelay.
6800    /// Performs a REAL account switch mid-publish: a different npub becomes
6801    /// current, with its own database. A generation bump used to stand in for
6802    /// this, which stopped meaning anything once routing moved onto the session.
6803    struct SwapDuringPublishRelay {
6804        inner: MemoryRelay,
6805        to: String,
6806        armed: std::sync::atomic::AtomicBool,
6807    }
6808
6809    impl SwapDuringPublishRelay {
6810        fn new() -> Self {
6811            let to = make_test_npub(TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
6812            std::fs::create_dir_all(crate::db::shared_test_data_dir().join(&to)).unwrap();
6813            assert_ne!(to, crate::db::get_current_account().unwrap(), "the fixture must swap to a DIFFERENT account");
6814            Self { inner: MemoryRelay::new(), to, armed: std::sync::atomic::AtomicBool::new(false) }
6815        }
6816        /// Swap on the NEXT publish only. The same relay serves the setup that
6817        /// builds the community, so the operation under test can still fetch the
6818        /// history it needs — a fresh relay would fail for want of editions
6819        /// rather than for the reason being tested.
6820        fn arm(&self) {
6821            self.armed.store(true, std::sync::atomic::Ordering::SeqCst);
6822        }
6823        fn swap_if_armed(&self) {
6824            if self.armed.swap(false, std::sync::atomic::Ordering::SeqCst) {
6825                crate::db::set_current_account(self.to.clone()).unwrap();
6826                crate::db::init_database(&self.to).unwrap();
6827            }
6828        }
6829    }
6830    #[async_trait::async_trait]
6831    impl Transport for SwapDuringPublishRelay {
6832        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
6833        async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
6834            self.swap_if_armed();
6835            self.inner.publish(event, relays).await
6836        }
6837        async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
6838            self.swap_if_armed();
6839            self.inner.publish_durable(event, relays).await
6840        }
6841        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
6842            self.inner.fetch(query, relays).await
6843        }
6844    }
6845
6846    /// A write straddling I/O stays with the account that issued it. A REAL swap
6847    /// lands during `set_member_grant`'s publish — the operation is already on the
6848    /// wire under account A, so it finishes into A's storage, and the account that
6849    /// swapped in never sees it.
6850    ///
6851    /// The old behaviour was to abandon the local persist, which left A published
6852    /// to relays but not recorded locally. Completing into A is both correct and
6853    /// what the user asked for.
6854    #[tokio::test]
6855    async fn account_swap_during_grant_publish_lands_in_the_issuing_account() {
6856        let (_tmp, _guard) = init_test_db();
6857        let issuer = crate::db::current_session();
6858        let swap = SwapDuringPublishRelay::new();
6859        let community = create_community(&swap, "HQ", "general", vec!["r1".into()]).await.unwrap();
6860        let cid = community.id.to_hex();
6861        let member = "cc".repeat(32);
6862        let entity_hex = crate::simd::hex::bytes_to_hex_32(
6863            &crate::community::derive::grant_locator(&community.id, &crate::simd::hex::hex_to_bytes_32(&member)));
6864        assert!(crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_none(), "no grant head yet");
6865
6866        swap.arm();
6867        set_member_grant(&swap, &community, &member, vec!["a".repeat(64)]).await.unwrap();
6868
6869        assert!(
6870            crate::db::with_session(issuer, async {
6871                crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_some()
6872            }).await,
6873            "the grant recorded against the account that issued it"
6874        );
6875        assert_eq!(crate::db::get_current_account().unwrap(), swap.to, "the swap really happened");
6876        assert!(
6877            crate::db::community::get_edition_head(&cid, &entity_hex).unwrap_or(None).is_none(),
6878            "and the account swapped in has no trace of it"
6879        );
6880    }
6881
6882    /// A swap during `publish_banlist`'s publish must leave NO half-applied state — the banlist isn't
6883    /// persisted, the private-community base isn't rotated, and `read_cut_pending` isn't flipped (every step
6884    /// belongs to the issuing account). No ban half-lands in the wrong account.
6885    #[tokio::test]
6886    async fn account_swap_during_ban_publish_applies_to_the_banning_account() {
6887        let (_tmp, _guard) = init_test_db();
6888        let banner = crate::db::current_session();
6889        let swap = SwapDuringPublishRelay::new();
6890        let community = create_community(&swap, "HQ", "general", vec!["r1".into()]).await.unwrap();
6891        let cid = community.id.to_hex();
6892        assert!(!is_public(&community).unwrap(), "fresh community is Private (a ban re-seals)");
6893
6894        swap.arm();
6895        publish_banlist(&swap, &community, &["cc".repeat(32)]).await.unwrap();
6896
6897        assert!(
6898            crate::db::with_session(banner, async {
6899                !crate::db::community::get_community_banlist(&cid).unwrap().is_empty()
6900            }).await,
6901            "the ban applied to the account that issued it — losing it would leave the \
6902             relays holding a ban the owner cannot see"
6903        );
6904        assert!(
6905            crate::db::community::get_community_banlist(&cid).unwrap_or_default().is_empty(),
6906            "and the account swapped in inherits no banlist"
6907        );
6908    }
6909
6910    /// `swap_session` leaves no cross-account residue — STATE and the key vaults are
6911    /// cleared, so account B can't inherit account A's chats/keys.
6912    #[tokio::test]
6913    async fn swap_session_clears_per_account_state_and_keys() {
6914        let (_tmp, _guard) = init_test_db();
6915        {
6916            let mut st = crate::state::STATE.lock().await;
6917            st.db_loaded = true;
6918            st.is_syncing = true;
6919        }
6920        assert!(crate::state::MY_SECRET_KEY.has_key(), "account A holds a live key");
6921
6922        crate::VectorCore.swap_session().await;
6923
6924        let st = crate::state::STATE.lock().await;
6925        assert!(st.chats.is_empty() && st.profiles.is_empty(), "STATE chats/profiles cleared on swap");
6926        assert!(!st.db_loaded && !st.is_syncing, "db_loaded / is_syncing reset");
6927        assert!(!crate::state::MY_SECRET_KEY.has_key(), "key vault cleared — no leak into account B");
6928    }
6929
6930    /// A clean join PERSISTS the community up front (so the catch-up/fold can read it
6931    /// back) and registers the channel as a chat. Without the up-front save, the fold's load returns None
6932    /// and nothing persists.
6933    #[tokio::test]
6934    async fn join_finalization_persists_and_registers_the_channel() {
6935        let (_tmp, _guard) = init_test_db();
6936        crate::state::STATE.lock().await.chats.clear(); // drop any residue from a prior serialized test
6937        let relay = MemoryRelay::new();
6938        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6939        // A different identity joins.
6940        become_local(&Keys::generate());
6941
6942        crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await.unwrap();
6943
6944        assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community persisted on join");
6945        assert!(!crate::state::STATE.lock().await.chats.is_empty(), "the channel is registered as a chat");
6946    }
6947
6948    /// If the folded banlist names the joiner, `am_i_banned` fires and the
6949    /// just-saved community is torn back DOWN, the join returns Err, and — since the presence beacon publish
6950    /// is AFTER the ban check — no phantom join is announced. No orphaned community row is left behind.
6951    #[tokio::test]
6952    async fn join_finalization_tears_down_a_banned_joiner() {
6953        let (_tmp, _guard) = init_test_db();
6954        let relay = MemoryRelay::new();
6955        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6956        // Go Public (mint a link) so the ban is anti-memberlist and does NOT rotate the base.
6957        create_public_invite(&relay, &community, None, None).await.unwrap();
6958        let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6959
6960        // Ban a would-be joiner, then become them.
6961        let joiner = Keys::generate();
6962        publish_banlist(&relay, &community, &[joiner.public_key().to_hex()]).await.unwrap();
6963        become_local(&joiner);
6964        assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community present pre-join");
6965
6966        let result = crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await;
6967        assert!(result.is_err(), "a banned joiner's finalize must fail");
6968        assert!(result.unwrap_err().to_string().contains("banned"), "the error names the ban");
6969        assert!(
6970            crate::db::community::load_community(&community.id).unwrap().is_none(),
6971            "the just-saved community is torn back down — no orphaned row for a banned joiner"
6972        );
6973    }
6974
6975    /// `delete_community` must wipe EVERY community-scoped table — a missed one leaves authority/key
6976    /// residue a leave/re-join would fold. Populates all six scoped tables (+ the denormalized banlist),
6977    /// deletes, asserts each is empty. `community_message_keys` is DELIBERATELY retained — those are our
6978    /// OWN send-side ephemeral signing keys, and the right to NIP-09-delete our own content from relays
6979    /// outlives membership (even after a ban/leave), so they must survive a community delete.
6980    #[tokio::test]
6981    async fn delete_community_wipes_every_community_scoped_table() {
6982        let (_tmp, _guard) = init_test_db();
6983        let relay = MemoryRelay::new();
6984        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6985        let cid = community.id.to_hex();
6986
6987        // Populate every community-scoped table.
6988        crate::db::community::store_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[0x11u8; 32]).unwrap();
6989        crate::db::community::save_public_invite("tok", &cid, "https://x/invite#y", None, None).unwrap();
6990        crate::db::community::save_pending_invite(&cid, "{}", "npub1inviter", 0).unwrap();
6991        crate::db::community::set_edition_head(&cid, &cid, 1, &[0x22u8; 32]).unwrap();
6992        crate::db::community::set_community_banlist(&cid, &["cc".repeat(32)], 100).unwrap();
6993
6994        // Sanity — all populated before the delete.
6995        assert!(crate::db::community::community_exists(&community.id).unwrap());
6996        assert!(!crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
6997        assert!(!crate::db::community::list_public_invites(&cid).unwrap().is_empty());
6998        assert!(crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid));
6999        assert!(!crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty());
7000        assert!(!crate::db::community::get_community_banlist(&cid).unwrap().is_empty());
7001
7002        crate::db::community::delete_community(&cid).unwrap();
7003
7004        // Every scoped table is empty for this community — no residue.
7005        assert!(!crate::db::community::community_exists(&community.id).unwrap(), "communities row gone");
7006        assert!(crate::db::community::load_community(&community.id).unwrap().is_none(), "community not loadable");
7007        assert!(crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys wiped");
7008        assert!(crate::db::community::list_public_invites(&cid).unwrap().is_empty(), "public invites wiped");
7009        assert!(!crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid), "pending invites wiped");
7010        assert!(crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty(), "edition heads wiped");
7011        assert!(crate::db::community::get_community_banlist(&cid).unwrap().is_empty(), "banlist wiped with the channels");
7012    }
7013
7014    /// A hostile relay piles JUNK at the control coordinate — a kind-3308 event at the right `#z` but
7015    /// with garbage content (not sealed under the server root). `open_control_edition` fails to decrypt it,
7016    /// so it's dropped before the fold; the genuine genesis plane still folds. No panic, no corruption.
7017    #[tokio::test]
7018    async fn fetch_control_folded_skips_junk_injected_at_the_coordinate() {
7019        let (_tmp, _guard) = init_test_db();
7020        let relay = MemoryRelay::new();
7021        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7022        let owner_hex = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
7023
7024        // Garbage 3308 at the real control pseudonym, ephemeral-signed (outers always are).
7025        let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
7026        let junk = nostr_sdk::prelude::EventBuilder::new(nostr_sdk::prelude::Kind::Custom(event_kind::COMMUNITY_CONTROL), "not a sealed edition")
7027            .tags([nostr_sdk::prelude::Tag::custom("z", [z])])
7028            .finalize(&Keys::generate())
7029            .unwrap();
7030        relay.publish(&junk, &community.relays).await.unwrap();
7031
7032        let folded = fetch_control_folded(&relay, &community).await.unwrap();
7033        assert!(
7034            !crate::community::roster::authorize_delegation(&folded, Some(&owner_hex)).roles.is_empty(),
7035            "the genuine Admin role still folds; the un-openable junk is silently dropped"
7036        );
7037    }
7038
7039    /// Every relay is dead/empty. The fold returns an empty roster, never a panic — a member with no
7040    /// reachable relay degrades to "no view," not a crash.
7041    #[tokio::test]
7042    async fn fetch_control_folded_on_dead_relays_is_empty_not_a_panic() {
7043        let (_tmp, _guard) = init_test_db();
7044        let community = saved_community_owned_by(&Keys::generate());
7045        let folded = fetch_control_folded(&FailingRelay, &community).await.unwrap();
7046        assert!(folded.roles.roles.is_empty() && folded.root_meta.is_none(), "dead relays → empty fold, no panic");
7047    }
7048
7049    #[tokio::test]
7050    async fn successful_private_ban_leaves_no_read_cut_pending() {
7051        // The happy path leaves no outstanding read-cut: the re-seal succeeds, so the flag is cleared.
7052        let (_tmp, _guard) = init_test_db();
7053        let relay = MemoryRelay::new();
7054        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7055        let cid = community.id.to_hex();
7056        publish_banlist(&relay, &community, &["cc".repeat(32)]).await.unwrap();
7057        assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "a successful re-seal leaves no pending read-cut");
7058        let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7059        assert_eq!(c.server_root_epoch, crate::community::Epoch(1));
7060    }
7061
7062    #[tokio::test]
7063    async fn failed_reseal_sets_pending_then_sync_retry_recovers() {
7064        // The recoverability fix (closes the #5c-1 HIGH for the total-outage case): a private ban whose
7065        // read-cut re-seal FAILS (the base rekey can't reach relays) still applies the ban, marks
7066        // `read_cut_pending`, and propagates the error — then a later community sync retries the re-seal
7067        // and recovers (the banned member's read access is finally cut), with no manual re-ban.
7068        let (_tmp, _guard) = init_test_db();
7069        let relay = RekeyFailingRelay::new(); // the base rekey (3303) will fail; the banlist edition lands
7070        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7071        let cid = community.id.to_hex();
7072        let victim = "cc".repeat(32);
7073
7074        // The ban applies (banlist persisted) but the read-cut re-seal fails → Err + pending set, base not rotated.
7075        assert!(publish_banlist(&relay, &community, &[victim.clone()]).await.is_err(), "the re-seal's base rekey fails");
7076        assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "a failed re-seal leaves read_cut_pending set");
7077        assert_eq!(crate::db::community::get_community_banlist(&cid).unwrap(), vec![victim.clone()], "the ban itself still applied");
7078        let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7079        assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "base NOT rotated while the re-seal is pending");
7080
7081        // The relay recovers; the sync-path retry re-attempts the read-cut and succeeds.
7082        relay.allow_rekey();
7083        retry_pending_read_cut(&relay, &c).await.unwrap();
7084        assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the retry succeeds");
7085        let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
7086        assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "the read-cut finally rotated the base");
7087    }
7088
7089    #[tokio::test]
7090    async fn privatize_reseals_to_observed_participants_not_just_owner() {
7091        // Regression for the bech32-vs-hex recipient bug (B1): privatize must re-seal to the OBSERVED
7092        // participants (parsed from the events table's BECH32 npubs), not collapse to owner-only. Alice
7093        // posts → she's observed → after privatize she is a base-rekey recipient and recovers the new root.
7094        use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
7095        use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
7096        use crate::types::Message;
7097        use nostr_sdk::prelude::ToBech32;
7098        let (_tmp, _guard) = init_test_db();
7099        let relay = MemoryRelay::new();
7100        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7101        let cid = community.id.to_hex();
7102        let genesis_root = *community.server_root_key.as_bytes();
7103        let channel_hex = community.channels[0].id.to_hex();
7104
7105        // Alice posts in the channel → community_member_activity observes her (bech32 npub in events).
7106        let alice = Keys::generate();
7107        let alice_b32 = alice.public_key().to_bech32().unwrap();
7108        let mut m = Message::default();
7109        m.id = "aa".repeat(32);
7110        m.npub = Some(alice_b32.clone());
7111        m.at = 1000;
7112        crate::db::events::save_message(&channel_hex, &m).await.unwrap();
7113        assert!(
7114            crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &alice_b32),
7115            "alice is an observed participant"
7116        );
7117
7118        // Mint a link → Public, then revoke it (last link) → privatize re-seals to {owner, alice}.
7119        let (token_hex, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
7120        revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token_hex)).await.unwrap();
7121        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
7122        assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "privatize rotated the base");
7123
7124        // Alice MUST be a recipient of the base rekey → recovers the new root (with the B1 bug she'd be
7125        // sealed out, leaving only the owner).
7126        let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
7127        let found = relay
7128            .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
7129            .await
7130            .unwrap();
7131        assert_eq!(found.len(), 1, "the base rekey is published");
7132        let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
7133        let secret = rekey_pairwise_secret(alice.secret_key(), &parsed.rotator).unwrap();
7134        let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
7135        let alice_blob = parsed.blobs.iter().find(|b| b.locator == loc).expect("alice's blob present (NOT sealed out)");
7136        let recovered = open_rekey_blob(alice.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, alice_blob).unwrap();
7137        assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "alice recovers the new root = owner's advanced base");
7138    }
7139
7140    #[tokio::test]
7141    async fn unpermissioned_invite_links_edition_is_rejected() {
7142        // authority: a creator's link edition counts only if they held CREATE_INVITE. A member without
7143        // it forging a link edition at their own coordinate (validly signed + version-shaped) is dropped
7144        // on fold — so an unpermissioned member can't flip the community Public.
7145        let (_tmp, _guard) = init_test_db();
7146        let relay = MemoryRelay::new();
7147        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7148
7149        let mallory = Keys::generate();
7150        let loc = "2b".repeat(32);
7151        let inner = crate::community::roster::build_invite_links_edition(&mallory, &community.id, &[loc], 1, None, 1000, None).unwrap();
7152        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7153        relay.inject(&outer, &community.relays);
7154
7155        let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7156        assert!(applied.is_empty(), "an unpermissioned member's link edition is rejected");
7157        assert!(!is_public(&community).unwrap(), "mode stays Private despite the forged edition");
7158    }
7159
7160    #[tokio::test]
7161    async fn invite_links_union_across_authorized_creators() {
7162        // per-creator: the owner AND a granted admin (both hold CREATE_INVITE) each publish their OWN
7163        // link edition; the fold UNIONS both authorized creators' locators into the aggregate. Proves
7164        // multiple creators + non-owner authorization (no shared registry, no MANAGE_INVITES).
7165        let (_tmp, _guard) = init_test_db();
7166        let relay = MemoryRelay::new();
7167        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7168        let cid = community.id.to_hex();
7169
7170        // Owner mints a link → their own per-creator edition.
7171        create_public_invite(&relay, &community, None, None).await.unwrap();
7172        let owner_loc = public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(
7173            &crate::db::community::list_public_invites(&cid).unwrap()[0].token));
7174
7175        // Grant `admin` the Admin role (carries CREATE_INVITE), then inject THEIR own link edition.
7176        let admin = Keys::generate();
7177        let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
7178        set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
7179        let admin_loc = "ab".repeat(32);
7180        let inner = crate::community::roster::build_invite_links_edition(&admin, &community.id, &[admin_loc.clone()], 1, None, 2000, None).unwrap();
7181        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7182        relay.inject(&outer, &community.relays);
7183
7184        let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7185        assert!(agg.contains(&owner_loc), "owner's link in the aggregate");
7186        assert!(agg.contains(&admin_loc), "the granted admin's link unions in too");
7187        assert!(is_public(&community).unwrap());
7188
7189        // B1: the owner revoking THEIR link must NOT privatize — the admin's link keeps it Public. The
7190        // revoke refreshes the aggregate from the relay first, so it sees the admin's still-live link even
7191        // if the local cache were stale. Base epoch stays 0 (no re-founding rekey).
7192        let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7193        let owner_token = crate::db::community::list_public_invites(&cid).unwrap()[0].token.clone();
7194        revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&owner_token)).await.unwrap();
7195        let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7196        assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "another creator's link remains → no privatize rekey");
7197        assert!(is_public(&c).unwrap(), "still Public (admin's link is live)");
7198    }
7199
7200    #[tokio::test]
7201    async fn invite_registry_retains_a_persisted_creator_on_a_partial_fold() {
7202        // Retain-on-absence: a fold served a PARTIAL control view (a relay
7203        // missing the link edition) must not wipe the persisted registry —
7204        // an empty registry misreads Private and routes a public ban through
7205        // the member-severing read-cut.
7206        let (_tmp, _guard) = init_test_db();
7207        let relay = MemoryRelay::new();
7208        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7209        let cid = community.id.to_hex();
7210
7211        create_public_invite(&relay, &community, None, None).await.unwrap();
7212        let owner_loc = public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(
7213            &crate::db::community::list_public_invites(&cid).unwrap()[0].token));
7214        let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7215        assert!(agg.contains(&owner_loc), "the mint folds + persists normally");
7216
7217        // A partial view: an (empty) relay set that never saw the edition.
7218        let partial = MemoryRelay::new();
7219        let agg = fetch_and_apply_invite_links(&partial, &community).await.unwrap();
7220        assert!(agg.contains(&owner_loc), "an absent edition retains the persisted locators");
7221        assert!(is_public(&community).unwrap(), "mode survives the partial view");
7222    }
7223
7224    #[tokio::test]
7225    async fn invite_registry_drops_a_demoted_creator_whose_edition_is_present() {
7226        // The inverse guard: presence-but-unauthorized is POSITIVE evidence of
7227        // demotion, so the stored row drops — retention keyed on the authorized
7228        // set instead would keep a demoted creator's links forever (a permanent
7229        // Public ratchet whose skipped read-cuts leave banned members reading).
7230        let (_tmp, _guard) = init_test_db();
7231        let relay = MemoryRelay::new();
7232        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7233        let cid = community.id.to_hex();
7234
7235        let admin = Keys::generate();
7236        let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
7237        set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
7238        let admin_loc = "ab".repeat(32);
7239        let inner = crate::community::roster::build_invite_links_edition(&admin, &community.id, &[admin_loc.clone()], 1, None, 2000, None).unwrap();
7240        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7241        relay.inject(&outer, &community.relays);
7242        let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7243        assert!(agg.contains(&admin_loc), "the granted admin's link folds + persists");
7244
7245        // Demote the admin. Their link edition is STILL on the relay, but the
7246        // fold now rejects it — and must not fall back to the stored row.
7247        set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![]).await.unwrap();
7248        let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7249        assert!(!agg.contains(&admin_loc), "a present-but-unauthorized edition drops the persisted row");
7250        assert!(!is_public(&community).unwrap(), "no live authorized link → Private");
7251    }
7252
7253    #[tokio::test]
7254    async fn failed_banlist_publish_does_not_persist_locally() {
7255        // Rollback honesty: if the ban edition never reaches relays, our local banlist must stay
7256        // untouched — else we'd one-sidedly drop a member's messages the rest of the community sees.
7257        let (_tmp, _guard) = init_test_db();
7258        let relay = MemoryRelay::new();
7259        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7260        let id_hex = community.id.to_hex();
7261        assert!(crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty());
7262
7263        let victim = "cc".repeat(32);
7264        let err = publish_banlist(&FailingRelay, &community, &[victim]).await;
7265        assert!(err.is_err(), "a failed publish must propagate");
7266        assert!(
7267            crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty(),
7268            "local banlist must be untouched when the publish failed"
7269        );
7270    }
7271
7272    #[tokio::test]
7273    async fn metadata_failed_publish_does_not_persist_locally() {
7274        // Metadata is RELAY-AUTHORITATIVE now (`fetch_and_apply_metadata` is the consumer fold): a failed
7275        // publish must NOT save locally, else we'd show an edit no member can see (and the phantom-head
7276        // rule keeps the edition head from advancing too). Convergence is publish-then-fold, not re-publish.
7277        let (_tmp, _guard) = init_test_db();
7278        let relay = MemoryRelay::new();
7279        let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7280        community.name = "Renamed HQ".to_string();
7281        assert!(republish_community_metadata(&FailingRelay, &community).await.is_err());
7282        let loaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
7283        assert_eq!(loaded.name, "HQ", "a failed metadata publish leaves the local name unchanged");
7284    }
7285
7286    #[tokio::test]
7287    async fn send_persists_key_then_delete_round_trip() {
7288        let (_tmp, _guard) = init_test_db();
7289        let relay = MemoryRelay::new();
7290        let community = Community::create("HQ", "general", vec!["r1".into()]);
7291        let channel = community.channels[0].clone();
7292        let alice = Keys::generate();
7293
7294        // send_message persists the ephemeral key keyed by the INNER message id...
7295        let _outer = send_message(&relay, &community, &channel, &alice, "deletable", 1).await.unwrap();
7296        let before = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
7297        assert_eq!(before.len(), 1);
7298        let message_id = before[0].message_id.to_hex();
7299
7300        // ...so delete_message (by inner message id, what the UI holds) removes it.
7301        delete_message(&relay, &message_id).await.unwrap();
7302        let after = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
7303        assert!(after.is_empty(), "message should be deleted after delete_message");
7304
7305        // The key is single-use: a second delete finds nothing retained.
7306        assert!(delete_message(&relay, &message_id).await.is_err());
7307    }
7308
7309    #[tokio::test]
7310    async fn failed_delete_publish_preserves_key() {
7311        // B2: the deletion key is single-use, so a FAILED NIP-09 publish must NOT consume
7312        // it — otherwise the message is permanently undeletable.
7313        let (_tmp, _guard) = init_test_db();
7314        let relay = MemoryRelay::new();
7315        let community = Community::create("HQ", "general", vec!["r1".into()]);
7316        let channel = community.channels[0].clone();
7317        let alice = Keys::generate();
7318        send_message(&relay, &community, &channel, &alice, "delete me", 1).await.unwrap();
7319        let message_id = fetch_channel_messages(&relay, &community, &channel).await.unwrap()[0]
7320            .message_id
7321            .to_hex();
7322
7323        // Delete via a transport whose publish fails → error, key retained.
7324        assert!(delete_message(&FailingRelay, &message_id).await.is_err());
7325
7326        // The key survived, so a retry over a working relay succeeds.
7327        delete_message(&relay, &message_id).await.unwrap();
7328        assert!(fetch_channel_messages(&relay, &community, &channel).await.unwrap().is_empty());
7329    }
7330
7331    #[tokio::test]
7332    async fn delete_unknown_message_errors() {
7333        let (_tmp, _guard) = init_test_db();
7334        let relay = MemoryRelay::new();
7335        // A message id we never sent → no retained key → error, no panic.
7336        let fake = Keys::generate();
7337        let bogus = EventBuilder::new(Kind::Custom(1), "x").finalize(&fake).unwrap().id;
7338        assert!(delete_message(&relay, &bogus.to_hex()).await.is_err());
7339    }
7340
7341    #[tokio::test]
7342    async fn accept_invite_persists_member_view() {
7343        let (_tmp, _guard) = init_test_db();
7344        let owner = Community::create("HQ", "general", vec!["r1".into()]);
7345        let invite = crate::community::invite::build_invite(&owner);
7346
7347        let joined = accept_invite(&invite).expect("accept");
7348        assert!(!is_proven_owner(&joined), "joined as member, not owner");
7349        // Persisted + reloadable with the same read keys.
7350        let loaded = crate::db::community::load_community(&owner.id).unwrap().expect("saved");
7351        assert_eq!(loaded.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
7352    }
7353
7354    #[tokio::test]
7355    async fn accept_invite_does_not_downgrade_owned_community() {
7356        // We OWN a Community (proven via the owner attestation); an invite reusing its id must be
7357        // refused so it can't overwrite our row.
7358        let (_tmp, _guard) = init_test_db();
7359        let relay = MemoryRelay::new();
7360        let owner = create_community(&relay, "HQ", "general", vec![]).await.unwrap();
7361        assert!(is_proven_owner(&owner), "we are the proven owner");
7362
7363        let invite = crate::community::invite::build_invite(&owner);
7364        let err = accept_invite(&invite).unwrap_err();
7365        assert!(err.contains("already own"), "must refuse to downgrade an owned community, got: {err}");
7366
7367        // The owner row is intact (same server-root key).
7368        let reloaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
7369        assert_eq!(reloaded.server_root_key.as_bytes(), owner.server_root_key.as_bytes());
7370    }
7371
7372    /// THE MIGRATION DOOR GATE. After a community flips to v2, its channel rows belong to the
7373    /// twin. `save_community`'s channel UPSERT re-parents on conflict, so redeeming a stale v1
7374    /// invite would silently steal every stitched row back to the dead v1 id — permanently, since
7375    /// the flip never re-runs (`migrated_to` is terminal). The gate must fire BEFORE any persist.
7376    #[tokio::test]
7377    async fn stale_v1_invite_cannot_reparent_a_migrated_communitys_channels() {
7378        let (_tmp, _guard) = init_test_db();
7379        // Hold a v1 community as a member, and keep a copy of the invite that got us in.
7380        let v1 = Community::create("Guild", "general", vec!["wss://r1".into()]);
7381        let stale_invite = crate::community::invite::build_invite(&v1);
7382        accept_invite(&stale_invite).expect("initial join");
7383        let v1_cid = v1.id.to_hex();
7384        let channel_hex = v1.channels[0].id.to_hex();
7385        assert_eq!(
7386            crate::db::community::community_id_for_channel(&channel_hex).unwrap().as_deref(),
7387            Some(v1_cid.as_str()),
7388            "precondition: the channel row starts parented to v1"
7389        );
7390
7391        // The migration lands: channels re-parent to the twin and the fence is stamped.
7392        let v2_cid = "9f".repeat(32);
7393        crate::db::community::reparent_channels_and_fence(&v1_cid, &v2_cid).unwrap();
7394        assert_eq!(
7395            crate::db::community::community_id_for_channel(&channel_hex).unwrap().as_deref(),
7396            Some(v2_cid.as_str()),
7397            "precondition: the flip moved the channel to the twin"
7398        );
7399
7400        // Redeem the stale v1 invite (the DM invite in the user's list, or an old link).
7401        let err = accept_invite(&stale_invite).unwrap_err();
7402        assert!(
7403            err.contains("upgraded to Concord v2"),
7404            "a migrated community must refuse a v1 re-accept, got: {err}"
7405        );
7406
7407        // The corruption itself: the channel row must STILL belong to the twin.
7408        assert_eq!(
7409            crate::db::community::community_id_for_channel(&channel_hex).unwrap().as_deref(),
7410            Some(v2_cid.as_str()),
7411            "the refused accept must not have re-parented the channel back to v1"
7412        );
7413        // And the fence is untouched, so nothing re-drives.
7414        assert_eq!(
7415            crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(),
7416            Some(v2_cid.as_str())
7417        );
7418    }
7419
7420    /// The gate is scoped to MIGRATED communities only: a live v1 community still accepts
7421    /// re-invites (re-accepts are legitimate and exempt from the membership cap), so the fix
7422    /// can't regress ordinary joins.
7423    #[tokio::test]
7424    async fn accept_invite_still_works_for_a_live_v1_community() {
7425        let (_tmp, _guard) = init_test_db();
7426        let v1 = Community::create("Guild", "general", vec!["wss://r1".into()]);
7427        let invite = crate::community::invite::build_invite(&v1);
7428        accept_invite(&invite).expect("initial join");
7429        // A second redeem of the same (still-live) community is accepted, not gated.
7430        accept_invite(&invite).expect("re-accept on a live v1 community must still work");
7431        assert!(crate::db::community::get_migrated_to(&v1.id.to_hex()).unwrap().is_none());
7432    }
7433
7434    /// A community migrated by SOMEONE ELSE that this device never held is a FRESH join — the
7435    /// `None` branch, deliberately ungated so the permanent on-ramp survives (save v1 → the
7436    /// carrier fold seals it → the drive flips the user into the twin). Guards against
7437    /// over-tightening the gate into the fresh-join path.
7438    #[tokio::test]
7439    async fn a_fresh_join_is_never_gated_by_another_communitys_fence() {
7440        let (_tmp, _guard) = init_test_db();
7441        // One community we hold and that has migrated.
7442        let migrated = Community::create("Old", "general", vec!["wss://r1".into()]);
7443        accept_invite(&crate::community::invite::build_invite(&migrated)).unwrap();
7444        crate::db::community::reparent_channels_and_fence(&migrated.id.to_hex(), &"9f".repeat(32)).unwrap();
7445
7446        // A DIFFERENT community, never held: the fresh-join path is unaffected.
7447        let fresh = Community::create("New", "general", vec!["wss://r2".into()]);
7448        accept_invite(&crate::community::invite::build_invite(&fresh)).expect("fresh join must not be gated");
7449        assert!(crate::db::community::load_community(&fresh.id).unwrap().is_some());
7450    }
7451
7452    /// The post-timelock door (#349). Past the wizard unlock a FRESH v1 join needs the
7453    /// owner's migration carrier at the dissolved coordinate (the permanent on-ramp into
7454    /// the v2 twin); a live v1 community refuses. Pre-unlock joins and held communities
7455    /// (re-accept / cross-device rehydrate) pass locally without a probe.
7456    #[tokio::test]
7457    async fn a_fresh_v1_join_past_the_timelock_needs_a_migration_carrier() {
7458        let (_tmp, _guard) = init_test_db();
7459        let relay = MemoryRelay::new();
7460        let unlock = crate::community::migration::MIGRATION_UNLOCK_AT;
7461
7462        let owner_keys = Keys::generate();
7463        become_local(&owner_keys);
7464        let owned = attested_community("Legacy", "general", vec!["wss://r1".into()]);
7465        let invite = crate::community::invite::build_invite(&owned);
7466        let member_view = crate::community::invite::accept_invite(&invite).expect("decode");
7467
7468        crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock - 1)
7469            .await
7470            .expect("pre-unlock fresh join passes without a probe");
7471
7472        let err = crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock)
7473            .await
7474            .unwrap_err();
7475        assert!(err.contains("legacy protocol"), "a live v1 community refuses post-unlock, got: {err}");
7476
7477        // The owner publishes the migration carrier; the same fresh join is now the v2 on-ramp.
7478        let sp = crate::community::migration::MigrationSignpost {
7479            v2_community_id: "ab".repeat(32),
7480            owner_xonly: owner_keys.public_key().to_hex(),
7481            owner_salt: "cd".repeat(32),
7482            relays: vec!["wss://r1".into()],
7483            name: "Legacy".into(),
7484            primary_channel: owned.channels[0].id.to_hex(),
7485            root_epoch: 0,
7486        };
7487        let content = crate::community::migration::build_migration_content(&sp, None).unwrap();
7488        publish_migration_carrier(&relay, &owned, &content).await.expect("carrier lands");
7489        crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock)
7490            .await
7491            .expect("a carrier-bearing community stays joinable (v2 on-ramp)");
7492
7493        // Held exemption: once the community is ours, the door never blocks a re-entry.
7494        crate::db::community::save_community(&member_view).unwrap();
7495        crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock)
7496            .await
7497            .expect("a held community passes post-unlock");
7498    }
7499
7500    /// The management doors save the caller's v1 struct on success (same blind UPSERT), so they
7501    /// carry the same fence — and refuse BEFORE publishing, so no orphan edition hits the relays.
7502    #[tokio::test]
7503    async fn metadata_republish_refuses_after_migration() {
7504        let (_tmp, _guard) = init_test_db();
7505        let owner = Keys::generate();
7506        become_local(&owner);
7507        let community = saved_community_owned_by(&owner);
7508        let cid = community.id.to_hex();
7509        let channel_id = community.channels[0].id;
7510        let relay = MemoryRelay::new();
7511
7512        crate::db::community::reparent_channels_and_fence(&cid, &"9f".repeat(32)).unwrap();
7513
7514        let err = republish_community_metadata(&relay, &community).await.unwrap_err();
7515        assert!(err.contains("upgraded to Concord v2"), "community metadata edit gated, got: {err}");
7516        let err = republish_channel_metadata(&relay, &community, &channel_id, "renamed").await.unwrap_err();
7517        assert!(err.contains("upgraded to Concord v2"), "channel rename gated, got: {err}");
7518        // Gated BEFORE the publish: a successful publish records its own edition head, so an
7519        // unadvanced head proves nothing reached the relays.
7520        assert!(
7521            crate::db::community::get_edition_head(&cid, &cid).unwrap().is_none(),
7522            "no community edition was published"
7523        );
7524        assert!(
7525            crate::db::community::get_edition_head(&cid, &channel_id.to_hex()).unwrap().is_none(),
7526            "no channel edition was published"
7527        );
7528    }
7529
7530    #[tokio::test]
7531    async fn accept_invite_rejects_id_collision_under_different_authority() {
7532        // We hold Community X as a MEMBER (authority pubkey A). A hostile bundle reuses
7533        // X's id but names a DIFFERENT authority + channel keys. It must be rejected so
7534        // our keys/authority/relays can't be silently swapped (community_id is
7535        // unauthenticated random bytes).
7536        let (_tmp, _guard) = init_test_db();
7537        let legit = Community::create("X", "general", vec!["wss://legit".into()]);
7538        let member_x = accept_invite(&crate::community::invite::build_invite(&legit)).unwrap();
7539        let original_key = member_x.channels[0].key.as_bytes().to_vec();
7540
7541        // Attacker's own Community, then forge its id to collide with X.
7542        let attacker = Community::create("evil", "general", vec!["wss://evil".into()]);
7543        let mut hostile = crate::community::invite::build_invite(&attacker);
7544        hostile.community_id = legit.id.to_hex();
7545        // The attacker's bundle carries its OWN server-root key, which differs from X's — the
7546        // keyless authority anchor the dedup compares.
7547        assert_ne!(hostile.server_root_key, crate::simd::hex::bytes_to_hex_32(member_x.server_root_key.as_bytes()));
7548
7549        assert!(accept_invite(&hostile).is_err(), "id-collision under new authority must be rejected");
7550
7551        // X's stored channel key is unchanged.
7552        let reloaded = crate::db::community::load_community(&legit.id).unwrap().unwrap();
7553        assert_eq!(reloaded.channels[0].key.as_bytes().to_vec(), original_key);
7554        assert_eq!(reloaded.relays, vec!["wss://legit".to_string()]);
7555    }
7556
7557    #[tokio::test]
7558    async fn rejected_accept_leaves_pending_invite_intact() {
7559        // Mirrors the accept command's peek→accept→(delete only on success) order: a
7560        // rejected accept must NOT destroy the parked invite (no silent data loss).
7561        let (_tmp, _guard) = init_test_db();
7562
7563        // We own this community (proven via the attestation), so an invite reusing its id is rejected.
7564        let owner = attested_community("HQ", "general", vec![]);
7565        crate::db::community::save_community(&owner).unwrap();
7566        let bundle = crate::community::invite::build_invite(&owner).to_json().unwrap();
7567        let cid = owner.id.to_hex();
7568        crate::db::community::save_pending_invite(&cid, &bundle, "npub1inviter", 0).unwrap();
7569
7570        // Command sequence: peek (no delete) → accept (errs) → row survives.
7571        let peeked = crate::db::community::get_pending_invite(&cid).unwrap().expect("parked");
7572        let invite = crate::community::invite::CommunityInvite::from_json(&peeked).unwrap();
7573        assert!(accept_invite(&invite).is_err(), "owning the id → reject");
7574        assert!(
7575            crate::db::community::pending_invite_exists(&cid).unwrap(),
7576            "rejected accept must leave the invite parked"
7577        );
7578
7579        // A successful accept (community we don't already hold) clears the row.
7580        let other = Community::create("Other", "general", vec![]);
7581        let ob = crate::community::invite::build_invite(&other).to_json().unwrap();
7582        let ocid = other.id.to_hex();
7583        crate::db::community::save_pending_invite(&ocid, &ob, "npub1inviter", 0).unwrap();
7584        let op = crate::db::community::get_pending_invite(&ocid).unwrap().unwrap();
7585        let oinvite = crate::community::invite::CommunityInvite::from_json(&op).unwrap();
7586        accept_invite(&oinvite).expect("accept ok");
7587        crate::db::community::delete_pending_invite(&ocid).unwrap();
7588        assert!(!crate::db::community::pending_invite_exists(&ocid).unwrap(), "cleared on success");
7589    }
7590
7591    #[tokio::test]
7592    async fn public_invite_create_fetch_accept_revoke_round_trip() {
7593        let (_tmp, _guard) = init_test_db();
7594        let relay = MemoryRelay::new();
7595        let mut owner = Community::create("Public HQ", "general", vec!["r1".into(), "r2".into()]);
7596        owner.description = Some("everyone welcome".into());
7597        // Sign the owner attestation with the seeded identity so `create_public_invite`'s proven-owner
7598        // gate passes. The owner community is in-memory only here (create_public_invite persists the
7599        // token, not the community), so this single DB cleanly plays the joiner on accept.
7600        let owner_keys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7601        owner.owner_attestation = Some(
7602            crate::community::owner::build_owner_attestation_unsigned(owner_keys.public_key(), &owner.id.to_hex())
7603                .finalize(&owner_keys).unwrap().as_json(),
7604        );
7605        // Owner mints a link.
7606        let (token_hex, url) = create_public_invite(&relay, &owner, None, None).await.expect("mint");
7607        assert!(url.contains('#'));
7608        assert_eq!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().len(), 1);
7609
7610        // A joiner parses the URL → fetches → previews → accepts.
7611        let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7612        assert_eq!(crate::simd::hex::bytes_to_hex_32(&token), token_hex);
7613        let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("fetch");
7614        assert_eq!(bundle.preview.name, "Public HQ");
7615        assert_eq!(bundle.preview.description.as_deref(), Some("everyone welcome"));
7616
7617        let joined = accept_public_invite(&bundle, 0).expect("accept");
7618        assert_eq!(joined.id, owner.id);
7619        assert_eq!(joined.description.as_deref(), Some("everyone welcome"), "preview patched in");
7620
7621        // Owner revokes the last link → the link no longer resolves AND the community re-founds (Private).
7622        revoke_public_invite(&relay, &owner, &token).await.expect("revoke");
7623        assert!(fetch_public_invite(&relay, &relays, &token).await.is_err(), "revoked link is dead");
7624        assert!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().is_empty());
7625    }
7626
7627    #[tokio::test]
7628    async fn revoked_invite_dies_even_if_one_relay_kept_the_bundle() {
7629        // Mixed-relay race (the exact case the tombstone defends): the tombstone replaces the bundle on r1,
7630        // but r2 was down during revoke and still serves the live bundle. fetch must STILL report the link
7631        // dead — a token-signed Revoked tombstone on ANY relay is authoritative and wins ties with a bundle.
7632        let (_tmp, _guard) = init_test_db();
7633        let relay = MemoryRelay::new();
7634        let owner = attested_community("HQ", "general", vec!["r1".into(), "r2".into()]);
7635        let (_token_hex, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
7636        let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7637        assert!(fetch_public_invite(&relay, &relays, &token).await.is_ok(), "live on both relays");
7638
7639        // The tombstone reaches ONLY r1 (replaces the bundle there); r2 still has the live bundle.
7640        let tombstone = public_invite::build_public_invite_tombstone(&token).unwrap();
7641        relay.inject(&tombstone, &["r1".to_string()]);
7642
7643        assert!(
7644            fetch_public_invite(&relay, &relays, &token).await.is_err(),
7645            "a tombstone on any one relay kills the link, even with a stale live bundle elsewhere",
7646        );
7647    }
7648
7649    #[tokio::test]
7650    async fn fetch_skips_relay_shadow_junk_to_genuine_bundle() {
7651        // A hostile relay piles a NEWER event at the same locator d-tag, signed by a
7652        // different key (relay-shadow attack). fetch must skip it (fails token verify)
7653        // and still surface the genuine bundle, not report "no invite".
7654        use nostr_sdk::prelude::{EventBuilder, Keys, Kind, Tag, Timestamp};
7655
7656        let (_tmp, _guard) = init_test_db();
7657        let relay = MemoryRelay::new();
7658        let owner = attested_community("HQ", "general", vec!["r1".into()]);
7659        let (_t, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
7660        let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7661
7662        // Attacker posts junk at the same locator with a far-future created_at so it
7663        // sorts newest.
7664        let attacker = Keys::generate();
7665        let junk = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "garbage")
7666            .tags([
7667                Tag::identifier(public_invite::locator_hex(&token)),
7668                Tag::custom("vsk", ["6".to_string()]),
7669                Tag::custom("v", ["1".to_string()]),
7670            ])
7671            .custom_created_at(Timestamp::from_secs(9_000_000_000))
7672            .finalize(&attacker)
7673            .unwrap();
7674        relay.publish(&junk, &relays).await.unwrap();
7675
7676        // Genuine bundle is still found despite the newer shadow.
7677        let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("genuine survives shadow");
7678        assert_eq!(bundle.preview.name, "HQ");
7679    }
7680
7681    #[tokio::test]
7682    async fn expired_public_invite_is_refused() {
7683        let (_tmp, _guard) = init_test_db();
7684        let relay = MemoryRelay::new();
7685        let owner = attested_community("HQ", "general", vec!["r1".into()]);
7686        let (_t, url) = create_public_invite(&relay, &owner, Some(1000), None).await.unwrap();
7687        let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7688        let bundle = fetch_public_invite(&relay, &relays, &token).await.unwrap();
7689        // Past expiry → accept refuses, nothing joined.
7690        assert!(accept_public_invite(&bundle, 2000).is_err());
7691        assert!(crate::db::community::load_community(&owner.id).unwrap().is_none());
7692    }
7693
7694    #[tokio::test]
7695    async fn republish_metadata_saves_and_publishes() {
7696        use crate::community::CommunityImage;
7697        let (_tmp, _guard) = init_test_db();
7698        let relay = MemoryRelay::new();
7699        // create_community mints the owner attestation (the seeded vault identity is the owner) and the
7700        // genesis GroupRoot edition (v1) — so the owner is proven + holds MANAGE_METADATA implicitly.
7701        let mut owner = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7702        let cid = owner.id.to_hex();
7703
7704        // Edit name + description + icon, republish (publishes the GroupRoot edition at v2).
7705        owner.name = "HQ Renamed".into();
7706        owner.description = Some("now with topic".into());
7707        owner.icon = Some(CommunityImage {
7708            url: "https://b/x".into(), key: "aa".repeat(32), nonce: "bb".repeat(12),
7709            hash: "cc".repeat(32), ext: "png".into(),
7710        });
7711        republish_community_metadata(&relay, &owner).await.expect("republish");
7712
7713        // Persisted locally.
7714        let loaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
7715        assert_eq!(loaded.name, "HQ Renamed");
7716        assert_eq!(loaded.description.as_deref(), Some("now with topic"));
7717        assert_eq!(loaded.icon.unwrap().url, "https://b/x");
7718
7719        // The GroupRoot edition advanced to v2 and carries the new metadata. Fetch the control plane,
7720        // fold the GroupRoot entity (entity_id == community_id), confirm the head + content.
7721        let (head_v, _) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
7722        assert_eq!(head_v, 2, "GroupRoot edition advanced v1 (create) → v2 (republish)");
7723        let z = crate::community::roster::control_pseudonym(&owner.server_root_key, &owner.id, crate::community::Epoch(0));
7724        let control = relay
7725            .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &owner.relays)
7726            .await
7727            .unwrap();
7728        let newest = control
7729            .iter()
7730            .filter_map(|o| crate::community::roster::open_control_edition(o, &owner.server_root_key).ok())
7731            .filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
7732            .filter(|p| p.entity_id == owner.id.0)
7733            .max_by_key(|p| p.version)
7734            .expect("GroupRoot edition on the relay");
7735        let meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&newest.content).unwrap();
7736        assert_eq!(meta.name, "HQ Renamed");
7737        assert_eq!(meta.icon.unwrap().ext, "png");
7738    }
7739
7740    #[tokio::test]
7741    async fn member_cannot_republish_metadata() {
7742        let (_tmp, _guard) = init_test_db();
7743        let relay = MemoryRelay::new();
7744        let owner = Community::create("HQ", "general", vec!["r1".into()]);
7745        let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
7746        assert!(republish_community_metadata(&relay, &member).await.is_err());
7747    }
7748
7749    #[tokio::test]
7750    async fn member_cannot_mint_public_invite() {
7751        let (_tmp, _guard) = init_test_db();
7752        let relay = MemoryRelay::new();
7753        let owner = Community::create("HQ", "general", vec!["r1".into()]);
7754        let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
7755        assert!(create_public_invite(&relay, &member, None, None).await.is_err(), "members can't mint links");
7756    }
7757
7758    #[tokio::test]
7759    async fn accept_oversized_bundle_rejected() {
7760        let (_tmp, _guard) = init_test_db();
7761        let owner = Community::create("HQ", "general", vec![]);
7762        let mut invite = crate::community::invite::build_invite(&owner);
7763        // Blow past the channel cap.
7764        let template = invite.channels[0].clone();
7765        for _ in 0..300 {
7766            invite.channels.push(template.clone());
7767        }
7768        assert!(accept_invite(&invite).is_err(), "oversized bundle must be rejected");
7769        assert!(crate::db::community::load_community(&owner.id).unwrap().is_none(), "nothing persisted");
7770    }
7771
7772    // --- owner dissolution (GroupDissolved tombstone) ---
7773
7774    /// Seal + publish a GroupDissolved tombstone (vsk=10) authored by `author` to the community's control
7775    /// plane at the CURRENT epoch, so a subsequent `fetch_and_apply_control` folds it. `created_at` is
7776    /// caller-chosen so a test can prove backdating doesn't gate the binary seal.
7777    async fn publish_tombstone<T: Transport + ?Sized>(transport: &T, community: &Community, author: &Keys, created_at: u64) {
7778        let inner = crate::community::roster::build_group_dissolved_edition_unsigned(author.public_key(), &community.id, created_at)
7779            .finalize(author).unwrap();
7780        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch).unwrap();
7781        transport.publish_durable(&outer, &community.relays).await.unwrap();
7782    }
7783
7784    /// A relay wrapping MemoryRelay that COUNTS rekey (3303) publishes — for asserting dissolution emits
7785    /// none. Everything else delegates to the inner relay.
7786    struct RekeyCountingRelay {
7787        inner: MemoryRelay,
7788        rekeys: std::sync::atomic::AtomicUsize,
7789    }
7790    impl RekeyCountingRelay {
7791        fn new() -> Self { Self { inner: MemoryRelay::new(), rekeys: std::sync::atomic::AtomicUsize::new(0) } }
7792        fn count(&self, e: &Event) {
7793            if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
7794                self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7795            }
7796        }
7797    }
7798    #[async_trait::async_trait]
7799    impl Transport for RekeyCountingRelay {
7800        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
7801        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish(e, r).await }
7802        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish_durable(e, r).await }
7803        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
7804    }
7805
7806    #[tokio::test]
7807    async fn owner_tombstone_folds_to_dissolved() {
7808        let (_tmp, _guard) = init_test_db();
7809        let relay = MemoryRelay::new();
7810        // The seeded local identity is the proven owner of a created community.
7811        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7812        let cid = community.id.to_hex();
7813        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7814        publish_tombstone(&relay, &community, &owner, 1000).await;
7815
7816        assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "alive before the fold");
7817        fetch_and_apply_control(&relay, &community).await.unwrap();
7818        assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "owner tombstone seals the community");
7819    }
7820
7821    #[tokio::test]
7822    async fn non_owner_tombstone_is_ignored() {
7823        let (_tmp, _guard) = init_test_db();
7824        let relay = MemoryRelay::new();
7825        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7826        let cid = community.id.to_hex();
7827        // A BAN-capable admin is NOT enough: dissolution is the owner's call alone. A random
7828        // non-owner author publishing the tombstone must be rejected.
7829        let mallory = Keys::generate();
7830        publish_tombstone(&relay, &community, &mallory, 1000).await;
7831
7832        fetch_and_apply_control(&relay, &community).await.unwrap();
7833        assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "a non-owner tombstone is ignored");
7834    }
7835
7836    #[tokio::test]
7837    async fn unreadable_deed_rejects_the_tombstone() {
7838        let (_tmp, _guard) = init_test_db();
7839        let relay = MemoryRelay::new();
7840        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7841        let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7842        let cid = community.id.to_hex();
7843        publish_tombstone(&relay, &community, &owner, 1000).await;
7844        // Strip the deed: the owner can no longer be derived → fail-closed, the tombstone is unverifiable.
7845        community.owner_attestation = None;
7846        crate::db::community::save_community(&community).unwrap();
7847        let stripped = crate::db::community::load_community(&community.id).unwrap().unwrap();
7848
7849        fetch_and_apply_control(&relay, &stripped).await.unwrap();
7850        assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "unverifiable tombstone is rejected, not death-by-default");
7851    }
7852
7853    #[tokio::test]
7854    async fn binary_seal_drops_every_subsequent_event_with_no_timestamp_test() {
7855        let (_tmp, _guard) = init_test_db();
7856        let relay = MemoryRelay::new();
7857        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7858        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7859        let cid = community.id.to_hex();
7860        publish_tombstone(&relay, &community, &owner, 1000).await;
7861        fetch_and_apply_control(&relay, &community).await.unwrap();
7862        assert!(crate::db::community::get_community_dissolved(&cid).unwrap());
7863
7864        // The channel reloaded after the seal carries the denormalized dissolved flag → inbound drops all.
7865        let sealed = crate::db::community::load_community(&community.id).unwrap().unwrap();
7866        let channel = sealed.channels[0].clone();
7867        let me = owner.public_key();
7868
7869        // A subsequent message — even BACKDATED before the tombstone — is dropped (no created_at gate).
7870        let backdated = super::super::envelope::seal_message(
7871            &Keys::generate(), &channel.key, &channel.id, channel.epoch, "ghost", 1,
7872        ).unwrap();
7873        let mut state = crate::state::ChatState::new();
7874        assert!(super::super::inbound::process_incoming(&mut state, &backdated, &channel, &me).is_none(),
7875            "a backdated message after the seal is dropped (binary seal, no timestamp test)");
7876
7877        // A subsequent control edition does not advance the fold either (it short-circuits on the flag).
7878        publish_tombstone(&relay, &sealed, &owner, 2000).await;
7879        assert_eq!(fetch_and_apply_control(&relay, &sealed).await.unwrap(), 0,
7880            "control fold stops advancing once sealed");
7881    }
7882
7883    #[tokio::test]
7884    async fn dissolve_community_emits_no_rekey_and_no_epoch_bump() {
7885        let (_tmp, _guard) = init_test_db();
7886        let relay = RekeyCountingRelay::new();
7887        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7888        let cid = community.id.to_hex();
7889        // Mint a public link so the link-retire path actually runs (and must NOT privatize-rekey).
7890        create_public_invite(&relay, &community, None, None).await.unwrap();
7891        let before_epoch = crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch;
7892
7893        dissolve_community(&relay, &community).await.unwrap();
7894
7895        assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "sealed locally");
7896        assert_eq!(relay.rekeys.load(std::sync::atomic::Ordering::Relaxed), 0,
7897            "dissolution publishes NO 3303 rekey (no last-link privatize re-founding)");
7898        assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch, before_epoch,
7899            "base epoch unchanged — dissolution rotates nothing");
7900    }
7901
7902    /// A migration-carrier tombstone (vsk=10 with a payload) seals the community AND persists
7903    /// the migration pointer in the same fold pass — the payload's one guaranteed ride on a
7904    /// live client before the seal short-circuits future control fetches.
7905    #[tokio::test]
7906    async fn migration_carrier_tombstone_seals_and_persists_the_pointer() {
7907        let (_tmp, _guard) = init_test_db();
7908        let relay = MemoryRelay::new();
7909        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7910        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7911        let cid = community.id.to_hex();
7912
7913        // Owner publishes a dissolution carrying a migration payload (signpost + sealed m).
7914        let signpost = crate::community::migration::MigrationSignpost {
7915            v2_community_id: "ab".repeat(32),
7916            owner_xonly: owner.public_key().to_hex(),
7917            owner_salt: "cd".repeat(32),
7918            relays: vec!["r1".into()],
7919            name: "HQ".into(),
7920            primary_channel: community.channels[0].id.to_hex(),
7921            root_epoch: 0,
7922        };
7923        let m = crate::community::migration::seal_m(community.server_root_key.as_bytes(), b"jm").unwrap();
7924        let content = crate::community::migration::build_migration_content(&signpost, Some(m)).unwrap();
7925        let inner = crate::community::roster::build_group_dissolved_edition_with_content(&owner, &community.id, 1000, &content).unwrap();
7926        let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch).unwrap();
7927        relay.publish_durable(&outer, &community.relays).await.unwrap();
7928
7929        fetch_and_apply_control(&relay, &community).await.unwrap();
7930
7931        assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "carrier still seals");
7932        let stored = crate::db::community::get_migration_pointer(&cid).unwrap().expect("pointer persisted");
7933        let parsed = crate::community::migration::parse_migration_payload(&stored).unwrap();
7934        assert_eq!(parsed.signpost.v2_community_id, "ab".repeat(32));
7935        assert!(parsed.m.is_some(), "the sealed key material rode along");
7936    }
7937
7938    /// The exemption: a base rekey may advance a SEALED community while a migration
7939    /// pointer is held and the target epoch is within the publish epoch — but a plain
7940    /// dissolution (no pointer) still refuses, and a flipped community (fence) refuses.
7941    #[test]
7942    fn migration_exemption_gates_the_dissolved_base_rekey() {
7943        let (_tmp, _guard) = init_test_db();
7944        let owner = Keys::generate();
7945        let me = Keys::generate();
7946        become_local(&me);
7947        let community = saved_community_owned_by(&owner);
7948        let cid = community.id.to_hex();
7949        crate::db::community::set_community_dissolved(&cid).unwrap();
7950
7951        let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &[0xCDu8; 32]);
7952        // No pointer → plain dissolution → still refuses (the existing invariant holds).
7953        assert!(apply_server_root_rekey(&community, &parsed).is_err());
7954
7955        // A migration pointer whose publish epoch covers epoch 1 → the walk is exempted.
7956        let signpost = crate::community::migration::MigrationSignpost {
7957            v2_community_id: "ab".repeat(32), owner_xonly: owner.public_key().to_hex(),
7958            owner_salt: "cd".repeat(32), relays: vec![], name: "x".into(),
7959            primary_channel: "ef".repeat(32), root_epoch: 5,
7960        };
7961        let content = crate::community::migration::build_migration_content(&signpost, Some("bTE=".into())).unwrap();
7962        crate::db::community::set_migration_pointer(&cid, &content).unwrap();
7963        assert!(crate::community::migration::catchup_exempt(&cid, 1), "epoch 1 <= publish epoch 5 → exempt");
7964        assert!(!crate::community::migration::catchup_exempt(&cid, 6), "beyond the publish epoch → not exempt");
7965
7966        // Once flipped, the fence overrides the exemption.
7967        crate::db::community::set_migrated_to(&cid, &"ab".repeat(32)).unwrap();
7968        assert!(!crate::community::migration::catchup_exempt(&cid, 1), "flipped → fence stands");
7969    }
7970
7971    #[tokio::test]
7972    async fn duplicate_owner_tombstones_are_idempotent() {
7973        let (_tmp, _guard) = init_test_db();
7974        let relay = MemoryRelay::new();
7975        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7976        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7977        let cid = community.id.to_hex();
7978        // Two owner tombstones (distinct created_at → distinct inner ids) at the locator.
7979        publish_tombstone(&relay, &community, &owner, 1000).await;
7980        publish_tombstone(&relay, &community, &owner, 2000).await;
7981
7982        fetch_and_apply_control(&relay, &community).await.unwrap();
7983        assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "duplicates still just dissolve, no error");
7984        // A second fold over the same plane is a harmless no-op (already sealed).
7985        assert_eq!(fetch_and_apply_control(&relay, &community).await.unwrap(), 0);
7986    }
7987
7988    #[test]
7989    fn apply_server_root_rekey_refuses_once_dissolved() {
7990        let (_tmp, _guard) = init_test_db();
7991        let owner = Keys::generate();
7992        let me = Keys::generate();
7993        become_local(&me);
7994        let community = saved_community_owned_by(&owner);
7995        let cid = community.id.to_hex();
7996        crate::db::community::set_community_dissolved(&cid).unwrap();
7997
7998        let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &[0xCDu8; 32]);
7999        assert!(apply_server_root_rekey(&community, &parsed).is_err(),
8000            "a base rekey cannot cross a tombstone");
8001        assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
8002            crate::community::Epoch(0), "base epoch did not advance");
8003    }
8004
8005    #[tokio::test]
8006    async fn tombstone_detected_after_a_base_rotation() {
8007        let (_tmp, _guard) = init_test_db();
8008        let relay = MemoryRelay::new();
8009        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
8010        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
8011        let cid = community.id.to_hex();
8012        // Re-found the base (epoch 0 → 1); the dissolved locator is rotation-STABLE, so a tombstone
8013        // published AFTER the rotation (sealed under the new root) is still found by a post-rotation client.
8014        rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
8015        let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
8016        assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
8017        publish_tombstone(&relay, &rotated, &owner, 1000).await;
8018
8019        fetch_and_apply_control(&relay, &rotated).await.unwrap();
8020        assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
8021            "tombstone at the rotation-stable locator is detected post-rotation");
8022    }
8023
8024    #[tokio::test]
8025    async fn stable_coordinate_tombstone_survives_a_concurrent_rotation() {
8026        // Cross-epoch: a tombstone published ONLY at the rotation-stable coordinate is
8027        // discovered by a client that has since advanced to a LATER epoch — whose control_pseudonym differs,
8028        // so the tombstone is NOT in that epoch's control fold. Only the stable-coordinate probe can find it.
8029        // This is the case a concurrent re-founding creates (tombstone at epoch N, joiner on epoch N+1).
8030        let (_tmp, _guard) = init_test_db();
8031        let relay = MemoryRelay::new();
8032        let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
8033        let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
8034        let cid = community.id.to_hex();
8035        // Owner publishes the tombstone ONLY at the stable coordinate (no control_pseudonym copy).
8036        let inner = crate::community::roster::build_group_dissolved_edition_unsigned(owner.public_key(), &community.id, 1000)
8037            .finalize(&owner).unwrap();
8038        let stable = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id).unwrap();
8039        relay.inject(&stable, &community.relays);
8040        // Advance the base epoch (the local client hasn't folded the tombstone yet, so rotation is allowed —
8041        // exactly the concurrent-re-founder's state). The control_pseudonym now differs from epoch 0's.
8042        rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
8043        let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
8044        assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
8045        assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "not folded yet");
8046        // Fetch control at the NEW epoch: the tombstone is absent from this control_pseudonym; only the
8047        // stable-coordinate probe can surface it.
8048        fetch_and_apply_control(&relay, &rotated).await.unwrap();
8049        assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
8050            "stable-coordinate probe discovers the tombstone cross-epoch (C3 closed)");
8051    }
8052}