use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp};
use super::super::transport::{Query, Transport};
use super::super::{version, ChannelId, Epoch};
use super::chat::{self, ChatEvent};
use super::community::{ChannelV2, CommunityV2};
use super::control;
use super::derive::{base_rekey_group_key, channel_group_key, channel_rekey_group_key, control_group_key, GroupKey};
use super::invite::{self, CommunityInvite};
use super::rekey::{self, Continuity, RekeyScope};
use super::{guestbook, stream, vsk};
use crate::community::edition::ParsedEdition;
use crate::state::SessionGuard;
fn me_pk() -> Result<PublicKey, String> {
crate::state::my_public_key().ok_or_else(|| "no active identity".to_string())
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub async fn create_community<T: Transport + ?Sized>(
transport: &T,
name: &str,
relays: Vec<String>,
description: Option<String>,
) -> Result<CommunityV2, String> {
let session = SessionGuard::capture();
let signer = crate::signer::active_signer()?;
let owner_pk = me_pk()?;
let at_ms = now_ms();
let meta = control::CommunityMetadata {
name: name.to_string(),
description: description.clone(),
relays: relays.clone(),
..Default::default()
};
let genesis = control::genesis_signed(owner_pk, &signer, meta, at_ms / 1000).await.map_err(|e| e.to_string())?;
let community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
if !session.is_valid() {
return Err("account changed during community creation".to_string());
}
let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
for wrap in &genesis.wraps {
if let Ok((ed, _)) = control::open_control_edition(wrap, &control) {
let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
}
}
crate::db::community::save_community_v2(&community)?;
let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
for wrap in &genesis.wraps {
transport.publish_durable(wrap, &community.relays).await?;
}
let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
let _ = transport.publish_durable(&join_wrap, &community.relays).await;
}
match republish_community_list(transport, Some(community.id())).await {
Ok(true) => {}
Ok(false) => republish_community_list_durable(Some(*community.id())),
Err(e) => {
crate::log_warn!("[CommunityList] failed to record this community across devices ({}) — retrying", e);
republish_community_list_durable(Some(*community.id()));
}
}
Ok(community)
}
pub async fn create_migration_twin<T: Transport + ?Sized>(
transport: &T,
name: &str,
relays: Vec<String>,
description: Option<String>,
primary: (ChannelId, String),
) -> Result<CommunityV2, String> {
let session = SessionGuard::capture();
let signer = crate::signer::active_signer()?;
let owner_pk = me_pk()?;
let at_ms = now_ms();
let meta = control::CommunityMetadata {
name: name.to_string(),
description: description.clone(),
relays: relays.clone(),
..Default::default()
};
let primary_name = primary.1.clone();
let genesis = control::genesis_signed_with_primary(owner_pk, &signer, meta, at_ms / 1000, Some(primary))
.await
.map_err(|e| e.to_string())?;
let mut community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
if let Some(ch) = community.channels.first_mut() {
ch.name = primary_name;
}
if !session.is_valid() {
return Err("account changed during twin creation".to_string());
}
let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
for wrap in &genesis.wraps {
if let Ok((ed, _)) = control::open_control_edition(wrap, &control) {
let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
}
}
crate::db::community::save_community_v2(&community)?;
let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
for wrap in &genesis.wraps {
transport.publish_durable(wrap, &community.relays).await?;
}
let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
let _ = transport.publish_durable(&join_wrap, &community.relays).await;
}
Ok(community)
}
pub async fn clone_banlist_to_twin<T: Transport + ?Sized>(
transport: &T,
twin: &CommunityV2,
banned: &[String],
) -> Result<(), String> {
if banned.is_empty() {
return Ok(());
}
set_banlist(transport, twin, banned).await
}
pub async fn clone_governance_to_twin<T: Transport + ?Sized>(
transport: &T,
twin: &CommunityV2,
v1_roles: &crate::community::roles::CommunityRoles,
banned: &[String],
) -> Result<(), String> {
use crate::community::roles::Permissions;
let owner = twin.owner()?;
for grant in &v1_roles.grants {
if !v1_roles.effective_permissions(&grant.member).contains(Permissions::ADMIN_FOUNDING_MASK) {
continue; }
if banned.contains(&grant.member) {
continue; }
let Ok(member) = PublicKey::parse(&grant.member) else { continue };
if member == owner {
continue; }
grant_admin(transport, twin, &member).await?;
}
Ok(())
}
pub fn twin_join_material(twin: &CommunityV2) -> super::list::JoinMaterial {
join_material(twin)
}
pub async fn send_message<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
content: &str,
) -> Result<String, String> {
send_chat_message(transport, community, channel_id, content, None, &[], vec![]).await
}
pub async fn send_chat_message<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
content: &str,
reply_to: Option<(&str, &str)>,
emoji: &[(&str, &str)],
extra_tags: Vec<nostr_sdk::prelude::Tag>,
) -> Result<String, String> {
send_chat_message_at(transport, community, channel_id, content, reply_to, emoji, extra_tags, now_ms()).await
}
#[allow(clippy::too_many_arguments)]
pub async fn send_chat_message_at<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
content: &str,
reply_to: Option<(&str, &str)>,
emoji: &[(&str, &str)],
extra_tags: Vec<nostr_sdk::prelude::Tag>,
at_ms: u64,
) -> Result<String, String> {
let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
let rumor = chat::build_message_rumor(author_pk, channel_id, epoch, content, reply_to, emoji, extra_tags, at_ms);
publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
}
#[allow(clippy::too_many_arguments)]
pub async fn send_reaction<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
target_id_hex: &str,
target_author_hex: &str,
target_kind: u16,
emoji_content: &str,
emoji: Option<(&str, &str)>,
) -> Result<String, String> {
let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
let at_ms = now_ms();
let rumor =
chat::build_reaction_rumor(author_pk, channel_id, epoch, target_id_hex, target_author_hex, target_kind, emoji_content, emoji, at_ms);
publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
}
pub async fn send_edit<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
target_id_hex: &str,
new_content: &str,
) -> Result<String, String> {
let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
let at_ms = now_ms();
let rumor = chat::build_edit_rumor(author_pk, channel_id, epoch, target_id_hex, new_content, at_ms);
publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
}
pub async fn send_delete<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
target_id_hex: &str,
target_kind: u16,
) -> Result<String, String> {
let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
let at_ms = now_ms();
let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, None);
let id = publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await?;
let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
spawn_pin_duty(&ch_hex, target_id_hex, None);
Ok(id)
}
pub async fn moderation_delete<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
target_id_hex: &str,
target_kind: u16,
target_author: &PublicKey,
) -> Result<String, String> {
let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
return Err("this community is dissolved — it accepts no new moderation actions".to_string());
}
let owner_hex = community.owner()?.to_hex();
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
if !crate::community::moderation::can_hide(
Some(&owner_hex),
&roster,
&author_pk.to_hex(),
&target_author.to_hex(),
) {
return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
}
let at_ms = now_ms();
let citation = required_authority_citation(community, &author_pk)?;
let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, citation.as_ref());
let id = publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await?;
let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
spawn_pin_duty(&ch_hex, target_id_hex, None);
Ok(id)
}
pub async fn send_webxdc_signal<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
topic_id: &str,
node_addr: Option<&str>,
) -> Result<(), String> {
let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
let at_ms = now_ms();
let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
let rumor = chat::build_webxdc_rumor(author_pk, channel_id, epoch, &content, vec![], at_ms);
publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await.map(|_| ())
}
pub async fn send_typing<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
) -> Result<(), String> {
let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
let at_ms = now_ms();
let rumor = chat::build_typing_rumor(author_pk, channel_id, epoch, at_ms);
publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
}
fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(PublicKey, GroupKey, Epoch, SessionGuard), String> {
let session = SessionGuard::capture();
let author_pk = me_pk()?;
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
return Err("this community has been dissolved".to_string());
}
if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author_pk.to_hex()) {
return Err("you are banned from this community".to_string());
}
let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
if ch.private && ch.key.is_none() {
return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
}
let (secret, epoch) = community.channel_secret(ch);
Ok((author_pk, channel_group_key(&secret, channel_id, epoch), epoch, session))
}
#[allow(clippy::too_many_arguments)]
async fn publish_chat<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
session: &SessionGuard,
group: &GroupKey,
author_pk: PublicKey,
channel_id: &ChannelId,
epoch: Epoch,
rumor: nostr_sdk::prelude::UnsignedEvent,
at_ms: u64,
ephemeral: bool,
) -> Result<String, String> {
let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
let signer = crate::signer::active_signer()?;
let (wrap, _p_tag_keys) = chat::seal_chat_rumor_signed(&signer, author_pk, &rumor, group, Timestamp::from_secs(at_ms / 1000), ephemeral).await
.map_err(|e| e.to_string())?;
if !session.is_valid() {
return Err("account changed before send".to_string());
}
transport.publish(&wrap, &community.relays).await?;
if !ephemeral {
if !session.is_valid() {
return Ok(rumor_id);
}
crate::db::community::store_message_key(&rumor_id, &wrap.id.to_hex(), group.keys(), &community.relays)?;
}
if !ephemeral {
if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
let outcome = {
let mut st = crate::state::STATE.lock().await;
if !session.is_valid() {
return Ok(rumor_id); }
super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author_pk)
};
if let Some(outcome) = outcome {
if !session.is_valid() {
return Ok(rumor_id);
}
super::inbound::persist_chat(&channel_hex, &outcome).await;
}
}
}
Ok(rumor_id)
}
pub struct FetchedEvent {
pub event: ChatEvent,
pub epoch: Epoch,
}
fn heal_own_wrap_key(event: &ChatEvent, group: &GroupKey, relays: &[String]) {
if !matches!(event, ChatEvent::Message { .. } | ChatEvent::Reaction { .. }) {
return;
}
let opened = event.opened();
if crate::state::my_public_key() != Some(opened.author) {
return;
}
let rumor_hex = opened.rumor_id.to_hex();
if !matches!(crate::db::community::get_message_key(&rumor_hex), Ok(None)) {
return;
}
if crate::db::community::store_message_key(&rumor_hex, &opened.wrapper_id.to_hex(), group.keys(), relays).is_ok() {
crate::traits::emit_event("message_delete_meta_changed", &serde_json::json!({ "id": rumor_hex }));
}
}
pub async fn fetch_channel<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
limit: usize,
) -> Result<Vec<FetchedEvent>, String> {
fetch_channel_history(transport, community, channel_id, limit, 1, None, None, crate::community::transport::Evidence::Quorum, |_| true).await
}
pub async fn fetch_channel_history<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
page: usize,
max_pages: usize,
since: Option<u64>,
start_until: Option<u64>,
evidence: crate::community::transport::Evidence,
mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
) -> Result<Vec<FetchedEvent>, String> {
let session = SessionGuard::capture();
let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let coords: Vec<([u8; 32], Epoch)> = if ch.private {
let Some(current) = ch.key else {
return Ok(Vec::new());
};
let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
held.push((ch.epoch, current));
}
held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
} else {
let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
roots.push((community.root_epoch, community.community_root));
}
roots.into_iter().map(|(ep, root)| (root, ep)).collect()
};
if coords.is_empty() {
return Ok(Vec::new());
}
let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
let mut seen_rumors = std::collections::HashSet::new();
let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
let mut until: Option<u64> = start_until;
let mut oldest: Option<u64> = None;
for _ in 0..max_pages {
let mut wraps: Vec<Event> = Vec::new();
let mut wrap_ids: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
for (secret, epoch) in &coords {
let plane = channel_group_key(secret, channel_id, *epoch);
let q = Query {
kinds: vec![stream::KIND_WRAP],
authors: vec![plane.pk_hex()],
since,
until,
limit: Some(page),
evidence,
..Default::default()
};
if let Ok(evs) = transport.fetch_plane(plane.keys(), &q, &community.relays).await {
for e in evs {
if wrap_ids.insert(e.id) {
wraps.push(e);
}
}
}
}
if wraps.is_empty() {
break;
}
let mut fresh = 0usize;
let mut page_events: Vec<FetchedEvent> = Vec::new();
for wrap in &wraps {
if !seen_wraps.insert(wrap.id) {
continue;
}
fresh += 1;
let at = wrap.created_at.as_secs();
if oldest.is_none_or(|o| at < o) {
oldest = Some(at);
}
for (secret, epoch) in &coords {
let group = channel_group_key(secret, channel_id, *epoch);
if wrap.pubkey != group.pk() {
continue;
}
if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
let id = event.opened().rumor_id;
if seen_rumors.insert(id) {
if session.is_valid() {
heal_own_wrap_key(&event, &group, &community.relays);
}
page_events.push(FetchedEvent { event, epoch: *epoch });
}
}
break;
}
}
if fresh == 0 {
if wraps.len() < page {
break; }
let Some(o) = oldest else { break };
if o == 0 {
break;
}
crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
until = Some(o - 1);
continue;
}
let stop = !page_events.is_empty() && !keep_paging(&page_events);
out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
if stop {
break; }
until = oldest; }
out.sort_by_key(|(ms, _)| *ms);
Ok(out.into_iter().map(|(_, e)| e).collect())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BundleAudience {
Link,
Member(PublicKey),
}
pub fn bundle_of(
community: &CommunityV2,
audience: BundleAudience,
creator: Option<PublicKey>,
expires_at_ms: Option<u64>,
label: Option<String>,
) -> CommunityInvite {
bundle_of_with_overlay(community, audience, creator, expires_at_ms, label, &[], &[])
}
pub fn bundle_of_with_overlay(
community: &CommunityV2,
audience: BundleAudience,
creator: Option<PublicKey>,
expires_at_ms: Option<u64>,
label: Option<String>,
with: &[String],
without: &[String],
) -> CommunityInvite {
let hex = crate::simd::hex::bytes_to_hex_32;
let cid_hex = hex(&community.identity.community_id.0);
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
let owner_hex = community.owner().ok().map(|o| o.to_hex());
let recipient_hex = match audience {
BundleAudience::Link => None,
BundleAudience::Member(pk) => Some(pk.to_hex()),
};
let channels = community
.vendable_channels(&roster, owner_hex.as_deref(), recipient_hex.as_deref(), with, without)
.into_iter()
.map(|c| invite::ChannelGrant {
id: hex(&c.id.0),
key: hex(&c.key.unwrap_or(community.community_root)),
epoch: c.epoch.0,
name: c.name.clone(),
})
.collect();
CommunityInvite {
community_id: hex(&community.identity.community_id.0),
owner: hex(&community.identity.owner_xonly),
owner_salt: hex(&community.identity.owner_salt),
community_root: hex(&community.community_root),
root_epoch: community.root_epoch.0,
channels,
relays: community.relays.clone(),
name: community.name.clone(),
icon: community.icon.clone(),
expires_at: expires_at_ms,
creator_npub: creator.map(|p| p.to_hex()),
label,
extra: Default::default(),
}
}
pub async fn send_direct_invite<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
recipient: &PublicKey,
expires_at_ms: Option<u64>,
label: Option<String>,
) -> Result<Event, String> {
let session = SessionGuard::capture();
assert_current_root(community)?;
let signer = crate::signer::active_signer()?;
let inviter_pk = me_pk()?;
let bundle = bundle_of(community, BundleAudience::Member(*recipient), Some(inviter_pk), expires_at_ms, label);
let wrap = invite::build_direct_invite_signed(&signer, inviter_pk, recipient, &bundle).await.map_err(|e| e.to_string())?;
if !session.is_valid() {
return Err("account changed before sending invite".to_string());
}
transport.publish(&wrap, &community.relays).await?;
Ok(wrap)
}
pub struct MintedLink {
pub url: String,
pub bundle_event: Event,
pub link_signer: Keys,
pub token: [u8; super::derive::TOKEN_LEN],
pub expires_at_ms: Option<u64>,
pub label: Option<String>,
}
pub async fn mint_public_link<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
base: &str,
expires_at_ms: Option<u64>,
label: Option<String>,
) -> Result<MintedLink, String> {
let session = SessionGuard::capture();
let mut token = [0u8; super::derive::TOKEN_LEN];
token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
let link_signer = Keys::generate();
let bundle = bundle_of(community, BundleAudience::Link, Some(me_pk()?), expires_at_ms, label.clone());
let bundle_key = super::derive::invite_bundle_key(&token);
let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
if !session.is_valid() {
return Err("account changed before minting link".to_string());
}
transport.publish_durable(&bundle_event, &community.relays).await?;
let minted = MintedLink { url, bundle_event, link_signer, token, expires_at_ms, label: label.clone() };
let _ = record_minted_link(transport, community, &minted).await;
if session.is_valid() {
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
}
Ok(minted)
}
async fn fetch_invite_list<T: Transport + ?Sized>(
transport: &T,
relays: &[String],
) -> Result<Option<invite::InviteList>, String> {
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
let query = Query {
kinds: vec![super::kind::INVITE_LIST],
authors: vec![my_pk.to_hex()],
limit: Some(4),
evidence: crate::community::transport::Evidence::Full,
..Default::default()
};
let events = transport.fetch(&query, relays).await?;
let mut best: Option<(u64, invite::InviteList)> = None;
for e in events {
if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
let at = e.created_at.as_secs();
if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
best = Some((at, l));
}
}
}
Ok(best.map(|(_, l)| l))
}
fn live_signers_for(list: &invite::InviteList, community_id_hex: &str, now_ms: u64) -> Vec<PublicKey> {
let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
list.entries
.iter()
.filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
.filter(|e| !e.expires_at.is_some_and(|exp| now_ms > exp))
.filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
.collect()
}
async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
let my_pk = me_pk()?;
let eid = super::derive::invite_links_locator(community.id(), &my_pk.to_bytes());
let content = invite::build_registry_content(live_signers);
publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content).await?;
refresh_invite_registry_cache(transport, community, session).await;
Ok(())
}
async fn refresh_invite_registry_cache<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard) {
let Ok(owner) = community.owner() else { return };
let Some(editions) = fetch_control_plane_whole(transport, community).await else { return };
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
.unwrap_or_default()
.into_iter()
.filter(|(_, f)| f.0 == community.root_epoch.0)
.map(|(entity, f)| (entity, (f.1, f.2, f.3)))
.collect();
let authority = fold_authority(community, &editions, &floors);
let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
if session.is_valid() {
let _ = crate::db::community::set_community_invite_registry(&cid_hex, &flatten_link_sets(&sets));
let _ = crate::db::community::replace_invite_link_sets(&cid_hex, &sets);
}
}
async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
let session = SessionGuard::capture();
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
if !list.entries.iter().any(|e| e.token == token_hex) {
list.entries.push(invite::InviteEntry {
token: token_hex,
signer_sk: minted.link_signer.secret_key().to_secret_hex(),
community_id: cid_hex.clone(),
url: minted.url.clone(),
label: minted.label.clone(),
created_at: now_ms() / 1000,
expires_at: minted.expires_at_ms,
extra: Default::default(),
});
}
if !session.is_valid() {
return Err("account changed during link record".to_string());
}
let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
transport.publish(&event, &community.relays).await?;
let signers = live_signers_for(&list, &cid_hex, now_ms());
publish_invite_registry(transport, community, &session, &signers).await
}
pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
let session = SessionGuard::capture();
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
let entry = list
.entries
.iter()
.find(|e| e.token == token_hex && e.community_id == cid_hex)
.cloned()
.ok_or("no such link in the invite list")?;
let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
if !session.is_valid() {
return Err("account changed during revoke".to_string());
}
transport.publish_durable(&revocation, &community.relays).await?;
list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
list.entries.retain(|e| e.token != token_hex);
let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
transport.publish(&event, &community.relays).await?;
let signers = live_signers_for(&list, &cid_hex, now_ms());
publish_invite_registry(transport, community, &session, &signers).await?;
if session.is_valid() {
let _ = crate::db::community::delete_public_invite(token_hex);
}
Ok(())
}
pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
let session = SessionGuard::capture();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
let query = Query {
kinds: vec![super::kind::INVITE_LIST],
authors: vec![my_pk.to_hex()],
limit: Some(4),
..Default::default()
};
let events = transport.fetch(&query, &community.relays).await?;
let mut best: Option<(u64, invite::InviteList)> = None;
for e in events {
if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
let at = e.created_at.as_secs();
if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
best = Some((at, l));
}
}
}
let Some((_, list)) = best else {
return Ok(());
};
let creator = my_pk;
let now = now_ms();
let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
for entry in &list.entries {
if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
continue;
}
if entry.expires_at.is_some_and(|exp| now > exp) {
continue;
}
let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
let bundle = bundle_of(community, BundleAudience::Link, Some(creator), entry.expires_at, entry.label.clone());
let bundle_key = super::derive::invite_bundle_key(&token);
if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
if !session.is_valid() {
return Err("account changed during link refresh".to_string());
}
let _ = transport.publish_durable(&event, &community.relays).await;
}
}
let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
if !mine_here {
return Ok(());
}
let signers = live_signers_for(&list, &cid_hex, now);
if !session.is_valid() {
return Err("account changed during link refresh".to_string());
}
let _ = publish_invite_registry(transport, community, &session, &signers).await;
Ok(())
}
pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
let Ok(owner) = community.owner() else { return false };
let Some(editions) = fetch_control_plane_whole(transport, community).await else { return true };
let cid = community.id();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
.unwrap_or_default()
.into_iter()
.filter(|(_, f)| f.0 == community.root_epoch.0)
.map(|(entity, f)| (entity, (f.1, f.2, f.3)))
.collect();
let authority = fold_authority(community, &editions, &floors);
!live_invite_link_sets(cid, &owner.to_hex(), &editions, &authority, &floors).is_empty()
}
async fn fetch_control_plane_whole<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Option<Vec<ParsedEdition>> {
let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
let mut editions: Vec<ParsedEdition> = Vec::new();
let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
let mut oldest: Option<u64> = None;
let mut until: Option<u64> = None;
for page in 0..COMPACT_MAX_PAGES {
let query = Query {
kinds: vec![stream::KIND_WRAP],
authors: vec![control.pk_hex()],
until,
limit: Some(FOLLOW_PAGE),
evidence: crate::community::transport::Evidence::Quorum,
..Default::default()
};
let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return None };
let mut fresh = 0usize;
for w in &wraps {
if !seen_wraps.insert(w.id) {
continue;
}
fresh += 1;
let at = w.created_at.as_secs();
if oldest.is_none_or(|o| at < o) {
oldest = Some(at);
}
if let Ok((ed, _)) = control::open_control_edition(w, &control) {
editions.push(ed);
}
}
if fresh == 0 {
if wraps.len() >= FOLLOW_PAGE {
return None; }
return Some(editions);
}
until = oldest;
if page + 1 == COMPACT_MAX_PAGES {
return None;
}
}
Some(editions)
}
fn live_invite_link_sets(
cid: &crate::community::CommunityId,
owner_hex: &str,
editions: &[ParsedEdition],
authority: &AuthoritySet,
floors: &Floors,
) -> Vec<crate::db::community::InviteLinkSetRow> {
use crate::community::roles::Permissions;
use std::collections::BTreeMap;
let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
for e in editions {
if e.vsk == vsk::INVITE_LINKS {
by_eid.entry(e.entity_id).or_default().push(e);
}
}
let mut sets: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
for (eid, group) in &by_eid {
let authed: Vec<&ParsedEdition> = group
.iter()
.copied()
.filter(|p| {
let author = p.author.to_hex();
!authority.banned.contains(&author)
&& authority.roles.is_authorized(&author, Some(owner_hex), Permissions::CREATE_INVITE)
&& super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
})
.collect();
if authed.is_empty() {
continue;
}
let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
if let Ok(signers) = invite::parse_registry_content(&authed[hi].content) {
if signers.is_empty() {
continue; }
sets.push(crate::db::community::InviteLinkSetRow {
creator_hex: authed[hi].author.to_hex(),
locators: signers.iter().map(|p| p.to_hex()).collect(),
});
}
}
sets
}
fn flatten_link_sets(sets: &[crate::db::community::InviteLinkSetRow]) -> Vec<String> {
let mut flat: Vec<String> = sets.iter().flat_map(|s| s.locators.iter().cloned()).collect();
flat.sort();
flat.dedup();
flat
}
async fn accept_bundle<T: Transport + ?Sized>(
transport: &T,
session: &SessionGuard,
bundle: &CommunityInvite,
invited_by: Option<PublicKey>,
announce_join: bool,
) -> Result<CommunityV2, String> {
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
let at_ms = now_ms();
if bundle.expired(at_ms) {
return Err("this invite has expired".to_string());
}
let community = CommunityV2::from_bundle(bundle, at_ms)?;
let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
v.session.is_valid()
&& v.at.elapsed() < VERIFIED_PREVIEW_TTL
&& v.community_id == community.id().0
&& v.community_root == community.community_root
});
let (community, join_heads, join_banlist) = match handoff {
Some(v) => {
let mut c = v.folded;
c.created_at_ms = at_ms;
(c, v.heads, v.banned)
}
None => verify_owner_root_and_reconcile(transport, community).await?,
};
if is_dissolved(transport, &community).await {
return Err("this community has been dissolved".to_string());
}
if join_banlist.contains(&my_pk.to_hex()) {
return Err("you are banned from this community".to_string());
}
if !session.is_valid() {
return Err("account changed during join".to_string());
}
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
for h in &join_heads {
crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, community.root_epoch.0)?;
}
crate::db::community::save_community_v2(&community)?;
let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
for ch in &community.channels {
if let (true, Some(key)) = (ch.private, ch.key) {
let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
}
}
if announce_join && !already_held {
let attribution = invited_by
.map(|p| p.to_hex())
.or_else(|| bundle.creator_npub.clone())
.zip(Some(bundle.label.clone().unwrap_or_default()));
let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
let _ = transport.publish(&join_wrap, &community.relays).await;
}
}
match republish_community_list(transport, Some(community.id())).await {
Ok(true) => {}
Ok(false) => republish_community_list_durable(Some(*community.id())),
Err(e) => {
crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
republish_community_list_durable(Some(*community.id()));
}
}
Ok(community)
}
async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
transport: &T,
community: CommunityV2,
) -> Result<(CommunityV2, Vec<FoldedHead>, std::collections::BTreeSet<String>), String> {
let owner = community.owner()?;
let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
let control_pk = control.pk_hex();
super::streamauth::prime(&community);
const PAGE: usize = 500;
const MAX_PAGES: usize = 4;
const FAR_FUTURE_SECS: u64 = 4_102_444_800; let mut editions: Vec<ParsedEdition> = Vec::new();
let mut all_editions: Vec<ParsedEdition> = Vec::new();
let mut found_genesis = false;
let mut compacted_metadata = false;
crate::log_debug!(
"[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
&control_pk[..12], community.root_epoch, community.relays
);
let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
};
for attempt in 0..2 {
editions.clear();
all_editions.clear();
compacted_metadata = false;
let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
for page_no in 0..MAX_PAGES {
let query = Query {
kinds: vec![stream::KIND_WRAP],
authors: vec![control_pk.clone()],
until,
limit: Some(PAGE),
evidence: crate::community::transport::Evidence::Full,
..Default::default()
};
let wraps = transport.fetch(&query, &community.relays).await?;
crate::log_trace!(
"[JoinVerify] attempt {} page {}: fetched {} wraps",
attempt, page_no, wraps.len()
);
let mut oldest = u64::MAX;
let mut fresh = 0usize;
for w in &wraps {
if !seen_wraps.insert(w.id) {
continue;
}
fresh += 1;
oldest = oldest.min(w.created_at.as_secs());
if let Ok((ed, _)) = control::open_control_edition(w, &control) {
crate::log_trace!(
"[JoinVerify] edition vsk={} eid={} owner={} at={}",
ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
ed.author == owner, w.created_at.as_secs()
);
if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
if ed.author == owner {
found_genesis = true;
} else {
compacted_metadata = true;
}
}
if ed.author == owner {
editions.push(ed.clone());
}
all_editions.push(ed);
}
}
crate::log_debug!(
"[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
);
if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
break; }
until = Some(oldest);
}
if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
break;
}
if attempt == 0 {
if let Some(client) = crate::state::nostr_client() {
super::streamauth::prime_auth(&client, &community.relays).await;
}
}
}
if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
return Err(
"could not verify this community from its relays (the invite may be forged, the relays are unreachable, or the control plane is being flooded); not joining"
.to_string(),
);
}
let empty_floors = Floors::new();
let authority = AuthoritySet::owner_only();
let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
let join_banlist = fold_authority(&community, &all_editions, &empty_floors).banned;
Ok((fold.updated.unwrap_or(community), fold.heads, join_banlist))
}
pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
let session = SessionGuard::capture();
let signer = crate::signer::active_signer()?;
let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
accept_bundle(transport, &session, &bundle, Some(inviter), true).await
}
pub async fn accept_parked_invite<T: Transport + ?Sized>(
transport: &T,
bundle_json: &str,
inviter_hex: Option<&str>,
) -> Result<CommunityV2, String> {
let session = SessionGuard::capture();
let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
accept_bundle(transport, &session, &bundle, invited_by, true).await
}
pub async fn accept_migration_material<T: Transport + ?Sized>(
transport: &T,
jm: &super::list::JoinMaterial,
) -> Result<CommunityV2, String> {
let session = SessionGuard::capture();
let bundle = material_to_invite(jm);
accept_bundle(transport, &session, &bundle, None, true).await
}
pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
let query = Query {
kinds: vec![super::kind::INVITE_BUNDLE],
authors: vec![parsed.link_signer.to_hex()],
..Default::default()
};
let relays = if parsed.bootstrap_relays.is_empty() {
invite::stock_relays()
} else {
parsed.bootstrap_relays.clone()
};
let events = match transport.fetch(&query, &relays).await {
Ok(evs) => evs,
Err(_) => {
wait_for_bootstrap_relay(&relays).await;
transport.fetch(&query, &relays).await?
}
};
let bundle_key = super::derive::invite_bundle_key(&parsed.token);
let mut newest_live: Option<(u64, CommunityInvite)> = None;
for event in &events {
match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
Ok(invite::BundleState::Live(bundle)) => {
let at = event.created_at.as_secs();
if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
newest_live = Some((at, *bundle));
}
}
Err(_) => {} }
}
newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
}
async fn wait_for_bootstrap_relay(relays: &[String]) {
let Some(client) = crate::state::nostr_client() else { return };
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8);
loop {
for url in relays {
if let Ok(Some(relay)) = client.relay(url).await {
if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
return;
}
}
}
if tokio::time::Instant::now() >= deadline {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
}
}
struct VerifiedPreview {
session: SessionGuard,
at: std::time::Instant,
community_id: [u8; 32],
community_root: [u8; 32],
folded: CommunityV2,
heads: Vec<FoldedHead>,
banned: std::collections::BTreeSet<String>,
}
static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
let bundle = fetch_public_bundle(transport, url).await?;
preview_bundle(transport, &bundle).await
}
pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
let community = CommunityV2::from_bundle(bundle, 0)?;
match verify_owner_root_and_reconcile(transport, community.clone()).await {
Ok((folded, heads, banned)) => {
*VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
session: SessionGuard::capture(),
at: std::time::Instant::now(),
community_id: folded.id().0,
community_root: folded.community_root,
folded: folded.clone(),
heads,
banned,
});
Ok(folded)
}
Err(_) => Ok(community),
}
}
pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
let session = SessionGuard::capture();
let bundle = fetch_public_bundle(transport, url).await?;
if !session.is_valid() {
return Err("account changed during join".to_string());
}
accept_bundle(transport, &session, &bundle, None, true).await
}
pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
let session = SessionGuard::capture();
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
let at_ms = now_ms();
let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
let _ = transport.publish(&wrap, &community.relays).await;
}
if !session.is_valid() {
return Err("account changed during leave".to_string());
}
let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
if !session.is_valid() {
return Err("account changed during leave".to_string());
}
crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
Ok(())
}
pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
let session = SessionGuard::capture();
assert_current_root(community)?;
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
let authority = fetch_authority(transport, community).await;
let owner_hex = community.owner()?.to_hex();
if !authority.roles.can_act_on_member(
&my_pk.to_hex(),
Some(&owner_hex),
&target.to_hex(),
crate::community::roles::Permissions::KICK,
) {
return Err("not authorized to kick this member".to_string());
}
let target_hex = target.to_hex();
let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
let may_strip = authority.roles.can_act_on_member(
&my_pk.to_hex(),
Some(&owner_hex),
&target_hex,
crate::community::roles::Permissions::MANAGE_ROLES,
);
if holds_roles && may_strip {
grant_roles(transport, community, target, Vec::new())
.await
.map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
if !session.is_valid() {
return Err("account changed during kick".to_string());
}
}
let at_ms = now_ms();
let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
let citation = required_authority_citation(community, &my_pk)?;
let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
.map_err(|e| e.to_string())?;
if !session.is_valid() {
return Err("account changed before send".to_string());
}
transport.publish(&wrap, &community.relays).await?;
Ok(())
}
pub struct AuthorityView {
pub roles: crate::community::roles::CommunityRoles,
pub banned: std::collections::BTreeSet<String>,
pub gapped: bool,
pub floored: std::collections::BTreeSet<String>,
pub head_entities: std::collections::BTreeSet<String>,
pub banned_at: std::collections::BTreeMap<String, u64>,
}
pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
.unwrap_or_default()
.into_iter()
.filter(|(_, f)| f.0 == community.root_epoch.0)
.map(|(entity, f)| (entity, (f.1, f.2, f.3)))
.collect();
let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
let mut editions: Vec<ParsedEdition> = Vec::new();
let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
let mut oldest: Option<u64> = None;
let mut until: Option<u64> = None;
let mut a = fold_authority(community, &[], &floors);
for _ in 0..FOLLOW_MAX_PAGES {
let query = Query {
kinds: vec![stream::KIND_WRAP],
authors: vec![control.pk_hex()],
until,
limit: Some(FOLLOW_PAGE),
evidence: crate::community::transport::Evidence::Quorum,
..Default::default()
};
let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
let mut fresh = 0usize;
for w in &wraps {
if !seen_wraps.insert(w.id) {
continue;
}
fresh += 1;
let at = w.created_at.as_secs();
if oldest.is_none_or(|o| at < o) {
oldest = Some(at);
}
if let Ok((ed, _)) = control::open_control_edition(w, &control) {
if seen.insert(ed.inner_id) {
editions.push(ed);
}
}
}
a = fold_authority(community, &editions, &floors);
if !a.gapped || fresh == 0 {
break;
}
until = oldest;
}
AuthorityView {
roles: a.roles,
banned: a.banned,
gapped: a.gapped,
floored: floors.keys().cloned().collect(),
head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
banned_at: a.banned_at,
}
}
async fn fetch_guestbook_events<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
since_secs: u64,
) -> Result<(Vec<guestbook::GuestbookEvent>, u64), String> {
let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
const GB_PAGE: usize = 500;
const GB_MAX_PAGES: usize = 12;
let mut events = Vec::new();
let mut newest: u64 = since_secs;
let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
let mut until: Option<u64> = None;
let mut oldest: Option<u64> = None;
for _ in 0..GB_MAX_PAGES {
let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_group.pk_hex()], until, limit: Some(GB_PAGE), evidence: crate::community::transport::Evidence::Full, ..Default::default() };
let wraps = transport.fetch(&query, &community.relays).await?;
let mut fresh = 0usize;
for wrap in &wraps {
if !seen.insert(wrap.id) {
continue;
}
fresh += 1;
let at = wrap.created_at.as_secs();
if oldest.is_none_or(|o| at < o) {
oldest = Some(at);
}
if at > newest {
newest = at;
}
if at < since_secs {
continue;
}
if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
events.push(ev);
}
}
}
if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
break;
}
match oldest {
Some(o) if o > 0 => until = Some(o),
_ => break,
}
}
Ok((events, newest))
}
fn fold_members(
community: &CommunityV2,
events: &[guestbook::GuestbookEvent],
mut observed: std::collections::BTreeMap<PublicKey, u64>,
roles: &crate::community::roles::CommunityRoles,
banlist: &std::collections::BTreeSet<PublicKey>,
banned_at: &std::collections::BTreeMap<PublicKey, u64>,
) -> Result<Vec<PublicKey>, String> {
let owner = community.owner()?;
let owner_hex = owner.to_hex();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
for g in &roles.grants {
if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
observed.entry(pk).or_insert(0);
}
}
let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
let actor_hex = actor.to_hex();
citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
&& roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
};
let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at);
if !banlist.contains(&owner) {
members.insert(owner);
}
Ok(members.into_iter().collect())
}
pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
return false;
};
let Ok(owner) = community.owner() else { return false };
let owner_hex = owner.to_hex();
let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
let actor_hex = actor.to_hex();
citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
&& roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
};
matches!(
guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
Some(st) if st.verdict == guestbook::Verdict::Kicked
)
}
pub async fn sync_guestbook<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
session: &SessionGuard,
) -> Result<Vec<guestbook::GuestbookEvent>, String> {
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
let since = cursor.saturating_sub(1);
let (fresh, newest) = fetch_guestbook_events(transport, community, since).await?;
if !session.is_valid() {
return Err("account changed during guestbook sync".to_string());
}
let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
let mut added = Vec::new();
for ev in fresh {
if !known.contains(&ev.rumor_id) {
events.push(ev.clone());
added.push(ev);
}
}
if !added.is_empty() || newest > cursor {
crate::db::community::set_guestbook(&cid_hex, &events, newest.max(cursor))?;
}
Ok(added)
}
pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
return Ok(false);
}
events.push(ev);
crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
Ok(true)
}
pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
if let Ok(pk) = PublicKey::parse(&npub) {
observed.insert(pk, last_active_secs.saturating_mul(1000));
}
}
let roles = crate::db::community::get_community_roles(&cid_hex)?;
let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
.unwrap_or_default()
.iter()
.filter_map(|h| PublicKey::from_hex(h).ok())
.collect();
let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
.unwrap_or_default()
.into_iter()
.filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
.collect();
fold_members(community, &events, observed, &roles, &banlist, &banned_at)
}
pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
let (events, _newest) = fetch_guestbook_events(transport, community, 0).await?;
let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
for ch in &community.channels {
if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
for f in &page {
let e = observed.entry(f.event.opened().author).or_insert(0);
*e = (*e).max(f.event.opened().at_ms);
}
}
}
let authority = fetch_authority(transport, community).await;
let banlist: std::collections::BTreeSet<PublicKey> =
authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
&crate::simd::hex::bytes_to_hex_32(&community.id().0),
)
.unwrap_or_default()
.into_iter()
.filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
.collect();
for (h, at) in &authority.banned_at {
if let Ok(pk) = PublicKey::from_hex(h) {
let slot = banned_at.entry(pk).or_insert(0);
*slot = (*slot).max(*at);
}
}
fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
}
pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
let session = SessionGuard::capture();
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
if community.owner()? != my_pk {
return Err("only the owner can dissolve a community".to_string());
}
let at = now_ms() / 1000;
let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
if !session.is_valid() {
return Err("account changed during dissolve".to_string());
}
transport.publish_durable(&wrap, &community.relays).await?;
crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
Ok(())
}
pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
let group = super::derive::dissolved_group_key(community.id());
let query = Query {
kinds: vec![stream::KIND_WRAP],
authors: vec![group.pk_hex()],
limit: Some(20),
..Default::default()
};
let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
return false;
};
wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
}
pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
let session = SessionGuard::capture();
let cid = community.id();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
return Err("this community has been dissolved; it cannot be re-founded".to_string());
}
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
let lock = super::realtime::follow_lock(cid);
let _guard = lock.lock().await;
let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
let community = &fresh;
let owner = community.owner()?;
{
let owner_hex = owner.to_hex();
let me_hex = my_pk.to_hex();
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
let authorized = my_pk == owner
|| (!banned.contains(&me_hex)
&& roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
&& removed.iter().all(|t| {
roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
}));
if !authorized {
return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
}
}
let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
.into_iter()
.filter(|(_, f)| f.0 == community.root_epoch.0)
.map(|(entity, f)| (entity, (f.1, f.2, f.3)))
.collect();
let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
let mut oldest: Option<u64> = None;
let mut until: Option<u64> = None;
let mut truncated = false;
for page in 0..COMPACT_MAX_PAGES {
let query = Query {
kinds: vec![stream::KIND_WRAP],
authors: vec![current_control.pk_hex()],
until,
limit: Some(FOLLOW_PAGE),
evidence: crate::community::transport::Evidence::Full,
..Default::default()
};
let wraps = transport.fetch(&query, &community.relays).await?;
let mut fresh = 0usize;
for w in &wraps {
if !seen_wraps.insert(w.id) {
continue;
}
fresh += 1;
let at = w.created_at.as_secs();
if oldest.is_none_or(|o| at < o) {
oldest = Some(at);
}
if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
opened.push(parsed);
}
}
if fresh == 0 {
truncated = wraps.len() >= FOLLOW_PAGE;
break;
}
until = oldest;
if page + 1 == COMPACT_MAX_PAGES {
truncated = true;
}
}
if truncated {
return Err(
"The community's control plane is too deep to read in full right now; re-founding stopped so no member is left behind.".to_string(),
);
}
let prev_epoch = community.root_epoch;
let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
if !session.is_valid() {
return Err("account changed during re-founding compaction".to_string());
}
let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
let new_control = control_group_key(&new_root, cid, new_epoch);
let at = now_ms();
let at_secs = at / 1000;
use std::collections::BTreeMap;
let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (i, (e, _)) in opened.iter().enumerate() {
by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
}
let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
for (floor_key, floor) in &floors {
let head_idx = by_eid
.get(floor_key)
.and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
let Some(head_idx) = head_idx else {
return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
};
let (head_ed, head_os) = &opened[head_idx];
let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
carried.push((h, rewrapped));
}
if !session.is_valid() {
return Err("account changed during re-founding acquire".to_string());
}
let members = memberlist(transport, community).await?;
let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
if !recipients.iter().any(|p| *p == my_pk) {
recipients.push(my_pk);
}
let mut base_blobs = Vec::new();
for r in &recipients {
base_blobs.push(
super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
.await
.map_err(|e| e.to_string())?,
);
}
let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
let base_chunks =
super::rekey::build_rekey_chunks(&signer, my_pk, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref())
.await
.map_err(|e| e.to_string())?;
let mut roster_for_channels = fetch_authority(transport, community).await.roles;
{
let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
for r in cached.roles {
if !roster_for_channels.roles.iter().any(|x| x.role_id == r.role_id) {
roster_for_channels.roles.push(r);
}
}
for g in cached.grants {
if !roster_for_channels.grants.iter().any(|x| x.member == g.member) {
roster_for_channels.grants.push(g);
}
}
}
if !session.is_valid() {
return Err("account changed during re-founding entitlement fetch".to_string());
}
let owner_hex_for_channels = community.owner().ok().map(|o| o.to_hex());
let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
for ch in &community.channels {
let (Some(old_key), true) = (ch.key, ch.private) else { continue };
let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
let entitled: Vec<PublicKey> = recipients
.iter()
.copied()
.filter(|r| {
*r == my_pk
|| roster_for_channels.is_entitled(owner_hex_for_channels.as_deref(), &r.to_hex(), &ch_hex, &[], &[])
})
.collect();
let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
if !session.is_valid() {
return Err("account changed during re-founding channel prepare".to_string());
}
let ch_new_key = mint_or_reuse_rotation_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch_new_epoch.0)?;
let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
let mut ch_blobs = Vec::new();
for r in &entitled {
ch_blobs.push(
super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
.await
.map_err(|e| e.to_string())?,
);
}
let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
let ch_chunks = super::rekey::build_rekey_chunks(&signer, my_pk, &ch_group, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, ch.epoch, &ch_prev_commit, &ch_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref())
.await
.map_err(|e| e.to_string())?;
channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
channel_chunk_sets.push(ch_chunks);
}
if !session.is_valid() {
return Err("account changed during re-founding prepare".to_string());
}
for c in &base_chunks {
transport.publish_durable(c, &community.relays).await?;
}
for set in &channel_chunk_sets {
for c in set {
transport.publish_durable(c, &community.relays).await?;
}
}
for (_, wrap) in &carried {
transport.publish_durable(wrap, &community.relays).await?;
}
let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
let snap_id = crate::community::random_32();
for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
let _ = transport.publish(&wrap, &community.relays).await;
}
}
if !session.is_valid() {
return Err("account changed during re-founding commit".to_string());
}
if crate::db::community::community_protocol(cid)?.is_none() {
return Ok(community.clone()); }
let mut updated = community.clone();
updated.community_root = new_root;
updated.root_epoch = new_epoch;
for (id, key, ep) in &channel_updates {
if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
c.key = Some(*key);
c.epoch = *ep;
}
}
crate::db::community::save_community_v2(&updated)?;
crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
for (h, _) in &carried {
crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
}
if let Some(client) = crate::state::nostr_client() {
super::realtime::refresh_subscription(&client).await;
}
for attempt in 0..3u8 {
match refresh_public_links(transport, &updated).await {
Ok(()) => break,
Err(_) if !session.is_valid() => break, Err(e) if attempt == 2 => {
crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
}
Err(_) => continue,
}
}
Ok(updated)
}
pub async fn refound_at_birth<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
snapshot_members: &[PublicKey],
) -> Result<CommunityV2, String> {
let session = SessionGuard::capture();
let cid = community.id();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
}
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
if my_pk != community.owner()? {
return Err("only the owner can birth-refound the migration twin".to_string());
}
let lock = super::realtime::follow_lock(cid);
let _guard = lock.lock().await;
let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
if community.root_epoch.0 == 1 {
return Ok(community);
}
if community.root_epoch.0 != 0 {
return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
}
let community = &community;
let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
.into_iter()
.filter(|(_, f)| f.0 == community.root_epoch.0)
.map(|(entity, f)| (entity, (f.1, f.2, f.3)))
.collect();
let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
let mut oldest: Option<u64> = None;
let mut until: Option<u64> = None;
let mut truncated = false;
for page in 0..COMPACT_MAX_PAGES {
let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![current_control.pk_hex()], until, limit: Some(FOLLOW_PAGE), evidence: crate::community::transport::Evidence::Full, ..Default::default() };
let wraps = transport.fetch(&query, &community.relays).await?;
let mut fresh = 0usize;
for w in &wraps {
if !seen_wraps.insert(w.id) { continue; }
fresh += 1;
let at = w.created_at.as_secs();
if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
opened.push(parsed);
}
}
if fresh == 0 {
truncated = wraps.len() >= FOLLOW_PAGE;
break;
}
until = oldest;
if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
}
if truncated {
return Err(
"The community's control plane is too deep to read in full right now; re-founding stopped so no member is left behind.".to_string(),
);
}
let prev_epoch = community.root_epoch; let new_epoch = Epoch(1);
let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
if !session.is_valid() {
return Err("account changed during birth-refound compaction".to_string());
}
let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
let new_control = control_group_key(&new_root, cid, new_epoch);
let at = now_ms();
let at_secs = at / 1000;
use std::collections::BTreeMap;
let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (i, (e, _)) in opened.iter().enumerate() {
by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
}
let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
for (floor_key, floor) in &floors {
let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
let Some(head_idx) = head_idx else {
return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
};
let (head_ed, head_os) = &opened[head_idx];
let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
carried.push((h, rewrapped));
}
if !session.is_valid() {
return Err("account changed during birth-refound acquire".to_string());
}
let base_blobs = vec![
super::rekey::build_blob(&signer, &my_pk.to_bytes(), &my_pk, super::rekey::RekeyScope::Root, new_epoch, &new_root)
.await
.map_err(|e| e.to_string())?,
];
let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
let base_chunks =
super::rekey::build_rekey_chunks(&signer, my_pk, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref())
.await
.map_err(|e| e.to_string())?;
if !session.is_valid() {
return Err("account changed during birth-refound prepare".to_string());
}
for c in &base_chunks {
transport.publish_durable(c, &community.relays).await?;
}
for (_, wrap) in &carried {
transport.publish_durable(wrap, &community.relays).await?;
}
let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
let snap_id = crate::community::random_32();
let snapshot_wraps: Vec<Event> = {
let mut out = Vec::new();
for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
.await
.map_err(|e| format!("seal birth snapshot: {e}"))?;
out.push(wrap);
}
out
};
for wrap in &snapshot_wraps {
transport.publish_durable(wrap, &community.relays).await?;
}
let verify_view = {
let mut v = community.clone();
v.community_root = new_root;
v.root_epoch = new_epoch;
v
};
let expected: Vec<PublicKey> = {
let banlist = fetch_authority(transport, &verify_view).await.banned;
snapshot_members.iter().copied()
.filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
.collect()
};
if !expected.is_empty() {
let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
let missing = expected.iter().filter(|m| !folded.contains(m)).count();
if missing > 0 {
return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
}
}
if !session.is_valid() {
return Err("account changed during birth-refound commit".to_string());
}
if crate::db::community::community_protocol(cid)?.is_none() {
return Ok(community.clone());
}
let mut updated = community.clone();
updated.community_root = new_root;
updated.root_epoch = new_epoch;
crate::db::community::save_community_v2(&updated)?;
crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
for (h, _) in &carried {
crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
}
Ok(updated)
}
fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
return Ok(existing);
}
let fresh = crate::community::random_32();
crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
Ok(fresh)
}
fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
let hex = crate::simd::hex::bytes_to_hex_32;
let channels = community
.channels
.iter()
.filter(|c| c.private)
.filter_map(|c| {
c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: Some(hex(&k)), epoch: c.epoch.0, name: c.name.clone() })
})
.collect();
super::list::JoinMaterial {
community_id: hex(&community.identity.community_id.0),
owner: hex(&community.identity.owner_xonly),
owner_salt: hex(&community.identity.owner_salt),
community_root: hex(&community.community_root),
root_epoch: community.root_epoch.0,
channels,
relays: community.relays.clone(),
name: community.name.clone(),
extra: Default::default(),
}
}
fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
let channels = jm
.channels
.iter()
.filter_map(|c| {
c.key.as_ref().map(|k| invite::ChannelGrant { id: c.id.clone(), key: k.clone(), epoch: c.epoch, name: c.name.clone() })
})
.collect();
CommunityInvite {
community_id: jm.community_id.clone(),
owner: jm.owner.clone(),
owner_salt: jm.owner_salt.clone(),
community_root: jm.community_root.clone(),
root_epoch: jm.root_epoch,
channels,
relays: jm.relays.clone(),
name: jm.name.clone(),
icon: None,
expires_at: None,
creator_npub: None,
label: None,
extra: Default::default(),
}
}
fn held_v2_relays() -> Vec<String> {
let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
if let Ok(ids) = crate::db::community::list_community_ids() {
for id in ids {
if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
set.extend(c.relays);
}
}
}
}
set.into_iter().collect()
}
async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
let query = Query {
kinds: vec![super::kind::COMMUNITY_LIST],
authors: vec![my_pk.to_hex()],
limit: Some(4),
..Default::default()
};
let events = transport.fetch(&query, relays).await?;
let seen = events.len();
let mut undecryptable = 0usize;
let mut unreadable: Option<(u64, String, usize, String)> = None;
let mut best: Option<(u64, String, super::list::CommunityList)> = None;
for e in events {
let at = e.created_at.as_secs();
let id_hex = e.id.to_hex();
let content_len = e.content.len();
match super::list::parse_list_event_signed(&signer, my_pk, &e).await {
Ok(l) => {
if best.as_ref().map(|(b, _, _)| at > *b).unwrap_or(true) {
best = Some((at, id_hex, l));
}
}
Err(err) => {
undecryptable += 1;
if unreadable.as_ref().map(|(a, _, _, _)| at > *a).unwrap_or(true) {
unreadable = Some((at, id_hex, content_len, err.to_string()));
}
}
}
}
if let Some((at, id, len, err)) = &unreadable {
if best.as_ref().map(|(b, _, _)| at > b).unwrap_or(true) {
crate::log_net_fail!(
"[CommunityList] IGNORED a newer copy {} created_at={at} ({len} content bytes) — falling back to stale membership: {err}",
&id[..8]
);
}
}
if let Some((at, id, l)) = &best {
let bytes = serde_json::to_string(l).map(|s| s.len()).unwrap_or(0);
crate::log_debug!(
"[CommunityList] using {} created_at={at} ({bytes}/{} bytes) of {seen} copies, {undecryptable} unreadable",
&id[..8],
super::stream::NIP44_MAX_PLAINTEXT
);
}
Ok(best.map(|(_, _, l)| l))
}
pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
let session = SessionGuard::capture();
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
let relays = held_v2_relays();
if relays.is_empty() {
return Ok(false); }
let remote = match fetch_community_list(transport, &relays).await {
Ok(r) => r.unwrap_or_default(),
Err(e) => {
crate::log_warn!(
"[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
e,
just_joined
.map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
.unwrap_or_default()
);
return Ok(false);
}
};
let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
let now = now_ms();
let mut local = super::list::CommunityList::default();
for id in crate::db::community::list_community_ids()? {
if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
continue;
}
let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
let tombstoned_at = remote
.tombstones
.iter()
.find(|t| t.community_id == cid_hex)
.map(|t| t.removed_at)
.unwrap_or(0);
let held_since = c.created_at_ms;
if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
crate::log_warn!(
"[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
&cid_hex[..8], tombstoned_at, held_since
);
continue;
}
let added_at = if remote.is_live(&cid_hex) && !is_join {
remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
} else if !is_join && tombstoned_at > 0 {
held_since
} else {
now
};
let jm = join_material(&c);
local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
}
let merged = remote.merge(&local);
merged.assert_fits().map_err(|e| e.to_string())?;
let event = super::list::build_list_event_signed(&signer, my_pk, &merged).await.map_err(|e| e.to_string())?;
if !session.is_valid() {
return Err("account changed during community-list publish".to_string());
}
if let Err(e) = transport.publish(&event, &relays).await {
crate::log_warn!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
return Err(e);
}
Ok(true)
}
const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
if crate::state::nostr_client().is_none() {
return;
}
let session = SessionGuard::capture();
tokio::spawn(async move {
for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
if !session.is_valid() {
return;
}
let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
match republish_community_list(&transport, just_joined.as_ref()).await {
Ok(true) => {
if attempt > 0 {
crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
}
return;
}
Ok(false) => {} Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
}
tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
}
crate::log_warn!(
"[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
LIST_REPUBLISH_BACKOFF_SECS.len()
);
});
}
async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
let mut doc = match fetch_community_list(transport, relays).await {
Ok(d) => d.unwrap_or_default(),
Err(e) => return Err(e),
};
let now = now_ms();
doc.tombstones.retain(|t| t.community_id != cid_hex);
doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
doc.assert_fits().map_err(|e| e.to_string())?;
let event = super::list::build_list_event_signed(&signer, my_pk, &doc).await.map_err(|e| e.to_string())?;
transport.publish(&event, relays).await
}
pub struct ListSyncOutcome {
pub joined: Vec<CommunityV2>,
pub removed: Vec<(String, Vec<String>)>,
}
pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
let session = SessionGuard::capture();
let mut relays = held_v2_relays();
relays.extend(bootstrap_relays.iter().cloned());
relays.sort();
relays.dedup();
if relays.is_empty() {
return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
}
let list = match fetch_community_list(transport, &relays).await {
Ok(Some(l)) => {
crate::log_debug!(
"[CommunityList] fetched: {} entries, {} tombstones, across {} relays",
l.entries.len(),
l.tombstones.len(),
relays.len()
);
l
}
Ok(None) => {
crate::log_debug!("[CommunityList] no kind-13302 across {} relays", relays.len());
return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
}
Err(e) => {
crate::log_net_fail!("[CommunityList] fetch failed across {} relays: {e}", relays.len());
return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
}
};
let mut removed: Vec<(String, Vec<String>)> = Vec::new();
for t in &list.tombstones {
if list.is_live(&t.community_id) {
continue;
}
let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
let id = crate::community::CommunityId(cid);
let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
continue; };
if held.created_at_ms > t.removed_at {
crate::log_warn!(
"[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
&t.community_id[..8], t.removed_at, held.created_at_ms
);
continue;
}
if !session.is_valid() {
return Err("account changed during community-list sync".to_string());
}
let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
let _ = crate::db::community::delete_community(&t.community_id);
removed.push((t.community_id.clone(), channel_ids));
}
let mut joined = Vec::new();
for entry in list.live_entries() {
let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
if crate::db::community::community_exists(&crate::community::CommunityId(cid)).unwrap_or(false) {
continue; }
if !session.is_valid() {
return Err("account changed during community-list sync".to_string());
}
let bundle = material_to_invite(&entry.current);
match accept_bundle(transport, &session, &bundle, None, false).await {
Ok(community) => joined.push(community),
Err(e) => crate::log_net_fail!(
"[CommunityList] {} is listed but adoption failed: {e}",
&entry.community_id[..entry.community_id.len().min(8)]
),
}
}
Ok(ListSyncOutcome { joined, removed })
}
pub(super) fn citation_is_synced(
cid_hex: &str,
owner_hex: &str,
actor_hex: &str,
citation: Option<&crate::community::edition::AuthorityCitation>,
) -> bool {
if owner_hex == actor_hex {
return true;
}
if citation.is_none() {
return false;
}
let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
&crate::community::CommunityId(cid_bytes),
&actor_bytes,
));
let head: Vec<crate::community::roster::EntityHead> =
crate::db::community::get_edition_head(cid_hex, &grant_hex)
.ok()
.flatten()
.map(|(version, self_hash)| crate::community::roster::EntityHead {
entity_hex: grant_hex.clone(),
version,
self_hash,
inner_id: [0u8; 32],
citation: None,
})
.into_iter()
.collect();
crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
}
fn required_authority_citation(
community: &CommunityV2,
actor: &PublicKey,
) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
if community.owner().ok().as_ref() == Some(actor) {
return Ok(None); }
my_authority_citation(community, actor).map(Some).ok_or_else(|| {
"your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
})
}
fn my_authority_citation(
community: &CommunityV2,
actor: &PublicKey,
) -> Option<crate::community::edition::AuthorityCitation> {
if community.owner().ok().as_ref() == Some(actor) {
return None;
}
let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
crate::db::community::get_edition_head(&cid_hex, &entity_hex)
.ok()
.flatten()
.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
}
fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
match crate::db::community::get_server_root_epoch(&cid_hex)? {
Some(held) if held != community.root_epoch.0 => Err(format!(
"the community re-founded mid-action (epoch {} -> {held}); retry",
community.root_epoch.0
)),
_ => Ok(()), }
}
async fn publish_control_edition<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
session: &SessionGuard,
vsk: &str,
entity_id: &[u8; 32],
content: &str,
) -> Result<(), String> {
assert_current_root(community)?;
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
Some((v, h)) => (v + 1, Some(h)),
None => (1, None),
};
let citation = required_authority_citation(community, &my_pk)?;
let at = now_ms() / 1000;
let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
if !session.is_valid() {
return Err("account changed before control publish".to_string());
}
transport.publish(&wrap, &community.relays).await?;
if !session.is_valid() {
return Ok(());
}
if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
}
Ok(())
}
fn merge_local_roster(cid_hex: &str, role: Option<&crate::community::roles::Role>, grant: Option<&crate::community::roles::MemberGrant>) {
let mut roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
if let Some(r) = role {
match roster.roles.iter_mut().find(|x| x.role_id == r.role_id) {
Some(slot) => *slot = r.clone(),
None => roster.roles.push(r.clone()),
}
}
if let Some(g) = grant {
match roster.grants.iter_mut().find(|x| x.member == g.member) {
Some(slot) => *slot = g.clone(),
None => roster.grants.push(g.clone()),
}
}
let at = crate::db::community::get_community_roles_at(cid_hex).unwrap_or(0);
if let Err(e) = crate::db::community::set_community_roles(cid_hex, &roster, at) {
crate::log_warn!("v2: local roster merge failed (heals on the next control fold): {e}");
}
}
pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
let session = SessionGuard::capture();
super::roles::validate_role(role)?;
let content = super::roles::role_content_json(role)?;
let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content).await
}
pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
let session = SessionGuard::capture();
let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
let content = super::roles::grant_content_json(&grant)?;
let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content).await
}
pub async fn ensure_admin_role<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
view: &AuthorityView,
create_if_missing: bool,
) -> Result<Option<String>, String> {
use crate::community::roles::{Permissions, Role, RoleScope};
if let Some(r) = view
.roles
.roles
.iter()
.find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK))
{
return Ok(Some(r.role_id.clone()));
}
if !create_if_missing {
return Ok(None);
}
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
set_role(transport, community, &Role::admin(role_id.clone())).await?;
Ok(Some(role_id))
}
pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
let session = SessionGuard::capture();
let my_pk = me_pk()?;
if my_pk != community.owner()? {
return Err("only the community owner can grant @admin".to_string());
}
let view = fetch_authority(transport, community).await;
if !session.is_valid() {
return Err("account changed during grant".to_string());
}
let member_hex = member.to_hex();
require_grant_head(community, &view, &member_hex)?;
let role_id = ensure_admin_role(transport, community, &view, true)
.await?
.expect("create_if_missing yields an id");
let mut role_ids = view
.roles
.grants
.iter()
.find(|g| g.member == member_hex)
.map(|g| g.role_ids.clone())
.unwrap_or_default();
if role_ids.contains(&role_id) {
return Ok(()); }
role_ids.push(role_id);
grant_roles(transport, community, member, role_ids).await
}
pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
let session = SessionGuard::capture();
let my_pk = me_pk()?;
if my_pk != community.owner()? {
return Err("only the community owner can revoke @admin".to_string());
}
let view = fetch_authority(transport, community).await;
if !session.is_valid() {
return Err("account changed during revoke".to_string());
}
let member_hex = member.to_hex();
require_grant_head(community, &view, &member_hex)?;
let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
return Ok(()); };
let mut role_ids = view
.roles
.grants
.iter()
.find(|g| g.member == member_hex)
.map(|g| g.role_ids.clone())
.unwrap_or_default();
let before = role_ids.len();
role_ids.retain(|r| r != &role_id);
if role_ids.len() == before {
return Ok(());
}
grant_roles(transport, community, member, role_ids).await
}
fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
return Err("malformed member key".to_string());
};
let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
}
Ok(())
}
pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
let session = SessionGuard::capture();
super::roles::validate_banlist(banned)?;
let content = super::roles::banlist_content_json(banned)?;
let eid = super::derive::banlist_locator(community.id());
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
Some((v, _)) => v + 1,
None => 1,
};
publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content).await?;
if session.is_valid() {
let _ = crate::db::community::set_community_banlist(&cid_hex, banned, version as i64);
}
Ok(())
}
pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
let session = SessionGuard::capture();
control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
}
pub async fn persist_community_image(
id: &crate::community::CommunityId,
img: control::ImageRef,
is_banner: bool,
session: &SessionGuard,
) -> Option<CommunityV2> {
let lock = super::realtime::follow_lock(id);
let _guard = lock.lock().await;
if !session.is_valid() {
return None;
}
let mut fresh = crate::db::community::load_community_v2(id).ok()??;
if is_banner {
fresh.banner = Some(img);
} else {
fresh.icon = Some(img);
}
crate::db::community::save_community_v2(&fresh).ok()?;
Some(fresh)
}
pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
let session = SessionGuard::capture();
let my_pk = me_pk()?;
ensure_channel_manager(community, &my_pk)?;
let old_name = community.channel(channel_id).map(|c| c.name.clone());
if meta.private {
if let Some(held) = community.channel(channel_id) {
if !held.private {
return Err("converting a public channel to private is not supported yet".to_string());
}
}
}
control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
if !session.is_valid() {
return Ok(());
}
if let Ok(Some(mut held)) = crate::db::community::load_community_v2(community.id()) {
if let Some(ch) = held.channels.iter_mut().find(|c| c.id.0 == channel_id.0) {
ch.name = meta.name.clone();
ch.private = meta.private;
ch.voice = meta.voice;
ch.meta_custom = meta.custom.clone();
ch.meta_extra = meta.extra.clone();
crate::db::community::save_community_v2(&held)?;
}
}
if meta.private {
if let Some(old) = old_name.filter(|o| *o != meta.name) {
rename_channel_access_role(transport, community, channel_id, &old, &meta.name, &session).await;
}
}
Ok(())
}
async fn rename_channel_access_role<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
old_name: &str,
new_name: &str,
session: &SessionGuard,
) {
let (Ok(my_pk), Ok(owner)) = (me_pk(), community.owner()) else {
return;
};
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
let mut roster = fetch_authority(transport, community).await.roles;
let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
for r in cached.roles {
if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
roster.roles.push(r);
}
}
if !session.is_valid() {
return;
}
let (me_hex, owner_hex) = (my_pk.to_hex(), owner.to_hex());
if !roster.is_authorized_in(&me_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
return;
}
let Some(mut role) = roster
.channel_roles(&chan_hex)
.into_iter()
.find(|r| r.permissions == crate::community::roles::Permissions::empty() && r.name == old_name)
.cloned()
else {
return;
};
if !roster.can_act_on_position(&me_hex, Some(&owner_hex), role.position, crate::community::roles::Permissions::MANAGE_ROLES) {
return;
}
role.name = new_name.to_string();
if let Err(e) = set_role(transport, community, &role).await {
crate::log_warn!("v2: channel renamed but its access role did not follow: {e}");
return;
}
if session.is_valid() {
merge_local_roster(&cid_hex, Some(&role), None);
}
}
fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
let owner = community.owner()?;
if *me == owner {
return Ok(());
}
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let me_hex = me.to_hex();
if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
return Err("you are banned from this community".to_string());
}
let roster = crate::db::community::get_community_roles(&cid_hex)?;
if roster.is_authorized(&me_hex, Some(&owner.to_hex()), crate::community::roles::Permissions::MANAGE_CHANNELS) {
Ok(())
} else {
Err("managing channels here needs the MANAGE_CHANNELS permission".to_string())
}
}
pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
let channel_id = ChannelId(super::super::random_32());
create_public_channel_with_id(transport, community, name, channel_id).await?;
Ok(channel_id)
}
pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
let session = SessionGuard::capture();
let lock = super::realtime::follow_lock(community.id());
let _guard = lock.lock().await;
let my_pk = me_pk()?;
ensure_channel_manager(community, &my_pk)?;
assert_channel_id_free(&channel_id, community.id())?;
let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
if !session.is_valid() {
return Err("account changed during channel create".to_string());
}
let mut updated = community.clone();
updated.channels.push(ChannelV2 { id: channel_id, name: name.to_string(), private: false, key: None, epoch: updated.root_epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
crate::db::community::save_community_v2(&updated)?;
Ok(())
}
fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
if existing != mine
&& matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
{
return Err("channel id is already live in another v2 community".to_string());
}
}
Ok(())
}
pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
let channel_id = ChannelId(super::super::random_32());
create_private_channel_with_id(transport, community, name, channel_id).await?;
Ok(channel_id)
}
pub fn channel_access_role(channel_id: &ChannelId, name: &str) -> crate::community::roles::Role {
use crate::community::roles::{Permissions, Role, RoleScope};
Role {
role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
name: name.to_string(),
position: u32::MAX - 1,
permissions: Permissions::empty(),
scope: RoleScope::Channel(crate::simd::hex::bytes_to_hex_32(&channel_id.0)),
color: 0,
}
}
pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
let session = SessionGuard::capture();
let lock = super::realtime::follow_lock(community.id());
let _guard = lock.lock().await;
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
ensure_channel_manager(community, &my_pk)?;
assert_channel_id_free(&channel_id, community.id())?;
let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
let channel_key = super::super::random_32();
let epoch = Epoch(1);
let access_role = channel_access_role(&channel_id, name);
let access_role_ids = vec![access_role.role_id.clone()];
let owner = community.owner()?;
let mut recipients = vec![my_pk];
if owner != my_pk {
recipients.push(owner);
}
let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
let mut blobs = Vec::with_capacity(recipients.len());
for r in &recipients {
blobs.push(
rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
.await
.map_err(|e| e.to_string())?,
);
}
let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
let at_secs = now_ms() / 1000;
let chunks = rekey::build_rekey_chunks(&signer, my_pk, &group, RekeyScope::Channel(channel_id), epoch, Epoch(0), &prev_commit, &blobs, at_secs, my_authority_citation(community, &my_pk).as_ref())
.await
.map_err(|e| e.to_string())?;
if !session.is_valid() {
return Err("account changed during channel create".to_string());
}
for c in &chunks {
transport.publish_durable(c, &community.relays).await?;
}
publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
if !session.is_valid() {
return Err("account changed during channel create".to_string());
}
set_role(transport, community, &access_role).await?;
grant_roles(transport, community, &my_pk, access_role_ids.clone()).await?;
if !session.is_valid() {
return Err("account changed during channel create".to_string());
}
merge_local_roster(
&crate::simd::hex::bytes_to_hex_32(&community.id().0),
Some(&access_role),
Some(&crate::community::roles::MemberGrant { member: my_pk.to_hex(), role_ids: access_role_ids }),
);
if crate::db::community::community_protocol(community.id())?.is_none() {
return Err("community removed during channel create".to_string());
}
let mut updated = community.clone();
updated.channels.push(ChannelV2 { id: channel_id, name: name.to_string(), private: true, key: Some(channel_key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
crate::db::community::save_community_v2(&updated)?;
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
let _ = refresh_public_links(transport, &updated).await;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VendVerdict {
Accept,
Park(&'static str),
Refuse(&'static str),
}
pub fn judge_channel_key_vend(
community: &CommunityV2,
roster: &crate::community::roles::CommunityRoles,
channel_id: &ChannelId,
epoch: Epoch,
sender_hex: &str,
) -> VendVerdict {
let me = match me_pk() {
Ok(pk) => pk.to_hex(),
Err(_) => return VendVerdict::Park("no active identity"),
};
let owner_hex = match community.owner() {
Ok(o) => o.to_hex(),
Err(_) => return VendVerdict::Refuse("community has no resolvable owner"),
};
let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
let Some(ch) = community.channel(channel_id) else {
return VendVerdict::Park("channel not in our fold yet");
};
if !ch.private {
return VendVerdict::Refuse("vend names a channel our fold says is public");
}
if ch.key.is_some() && epoch.0 <= ch.epoch.0 {
return VendVerdict::Refuse("superseded: we already hold this epoch or newer");
}
if epoch.0 > ch.epoch.0.saturating_add(MAX_VEND_EPOCH_LEAD) {
return VendVerdict::Refuse("vend epoch is implausibly far ahead of the channel head");
}
if !roster.is_entitled(Some(&owner_hex), &me, &chan_hex, &[], &[]) {
return VendVerdict::Park("our grant for this channel has not folded yet");
}
if sender_hex != owner_hex && !roster.is_entitled(Some(&owner_hex), sender_hex, &chan_hex, &[], &[]) {
return VendVerdict::Park("vendor's entitlement has not folded yet");
}
VendVerdict::Accept
}
const PARKED_VEND_TTL_SECS: u64 = 30 * 24 * 3600;
const MAX_VEND_EPOCH_LEAD: u64 = 1024;
pub fn absorb_parked_channel_keys(community: &CommunityV2, session: &SessionGuard) -> Vec<ChannelId> {
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let parked = match crate::db::community::get_pending_channel_keys(&cid_hex) {
Ok(p) if !p.is_empty() => p,
_ => return Vec::new(),
};
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
let mut adopted = Vec::new();
let now = now_ms() / 1000;
for p in parked {
if adopted.iter().any(|c: &ChannelId| crate::simd::hex::bytes_to_hex_32(&c.0) == p.channel_id) {
let _ = crate::db::community::drop_pending_channel_key(p.id);
continue;
}
if now.saturating_sub(p.received_at.max(0) as u64) > PARKED_VEND_TTL_SECS {
let _ = crate::db::community::drop_pending_channel_key(p.id);
continue;
}
let Some(id_bytes) = crate::simd::hex::hex_to_bytes_32_checked(&p.channel_id) else {
let _ = crate::db::community::drop_pending_channel_key(p.id);
continue;
};
let channel_id = ChannelId(id_bytes);
match judge_channel_key_vend(community, &roster, &channel_id, Epoch(p.epoch), &p.sender) {
VendVerdict::Accept => {
if !session.is_valid() {
return adopted;
}
let keyless = community.channel(&channel_id).is_some_and(|c| c.key.is_none());
let seated = if keyless {
crate::db::community::seat_channel_key(&cid_hex, &p.channel_id, p.epoch, &p.key)
} else {
crate::db::community::advance_channel_epoch(&cid_hex, &p.channel_id, p.epoch, &p.key).map(|_| ())
};
if let Err(e) = seated {
crate::log_warn!("v2: adopting a vended channel key failed: {e}");
continue;
}
let _ = crate::db::community::drop_pending_channel_keys_for(&cid_hex, &p.channel_id);
adopted.push(channel_id);
}
VendVerdict::Refuse(why) => {
crate::log_warn!("v2: refused a vended channel key for {}: {why}", p.channel_id);
let _ = crate::db::community::drop_pending_channel_key(p.id);
}
VendVerdict::Park(_) => {}
}
}
adopted
}
pub async fn grant_channel_access<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
member: &PublicKey,
) -> Result<(), String> {
let session = SessionGuard::capture();
let my_pk = me_pk()?;
let ch = community.channel(channel_id).ok_or("unknown channel")?;
if !ch.private {
return Err("channel is public — every member already reads it".to_string());
}
if ch.key.is_none() {
return Err("we hold no key for this channel, so we cannot vend it".to_string());
}
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
let owner_hex = community.owner()?.to_hex();
let mut roster = fetch_authority(transport, community).await.roles;
let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
for r in cached.roles {
if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
roster.roles.push(r);
}
}
for g in cached.grants {
if !roster.grants.iter().any(|x| x.member == g.member) {
roster.grants.push(g);
}
}
if !session.is_valid() {
return Err("account changed during grant".to_string());
}
if !roster.is_authorized_in(&my_pk.to_hex(), Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
return Err("not authorized to manage this channel's access".to_string());
}
let role_id = roster
.channel_roles(&chan_hex)
.into_iter()
.find(|r| r.permissions == crate::community::roles::Permissions::empty())
.map(|r| r.role_id.clone())
.ok_or("channel has no permission-less access role to grant")?;
let mut role_ids: Vec<String> = roster.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect();
if !role_ids.contains(&role_id) {
role_ids.push(role_id.clone());
}
grant_roles(transport, community, member, role_ids.clone()).await?;
if !session.is_valid() {
return Err("account changed during grant".to_string());
}
merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids }));
let bundle = bundle_of_with_overlay(
community,
BundleAudience::Member(*member),
Some(my_pk),
None,
None,
std::slice::from_ref(&role_id),
&[],
);
let signer = crate::signer::active_signer()?;
let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
if !session.is_valid() {
return Err("account changed before vending the key".to_string());
}
transport.publish(&wrap, &community.relays).await?;
Ok(())
}
pub async fn revoke_channel_access<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
member: &PublicKey,
) -> Result<(), String> {
let session = SessionGuard::capture();
let my_pk = me_pk()?;
let ch = community.channel(channel_id).ok_or("unknown channel")?;
if !ch.private {
return Err("channel is public — there is no access to revoke".to_string());
}
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
let owner_hex = community.owner()?.to_hex();
let mut roster = fetch_authority(transport, community).await.roles;
let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
for r in cached.roles {
if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
roster.roles.push(r);
}
}
for g in cached.grants {
if !roster.grants.iter().any(|x| x.member == g.member) {
roster.grants.push(g);
}
}
if !session.is_valid() {
return Err("account changed during revoke".to_string());
}
if !roster.is_authorized_in(&my_pk.to_hex(), Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
return Err("not authorized to manage this channel's access".to_string());
}
if *member == community.owner()? {
return Err("the owner is supreme and cannot be removed".to_string());
}
let access_ids = roster.channel_role_ids(&chan_hex);
if access_ids.is_empty() {
return Err("this channel's access role has not folded yet — retry once the control plane serves it".to_string());
}
let remaining: Vec<String> = roster
.roles_of(&member.to_hex())
.map(|r| r.role_id.clone())
.filter(|id| !access_ids.contains(id))
.collect();
grant_roles(transport, community, member, remaining.clone()).await?;
if !session.is_valid() {
return Err("account changed during revoke".to_string());
}
merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids: remaining }));
rekey_channel_excluding(transport, community, channel_id, &roster, &access_ids, member).await
}
async fn rekey_channel_excluding<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
roster: &crate::community::roles::CommunityRoles,
access_ids: &[String],
removed: &PublicKey,
) -> Result<(), String> {
let session = SessionGuard::capture();
let lock = super::realtime::follow_lock(community.id());
let _guard = lock.lock().await;
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
let ch = community.channel(channel_id).ok_or("unknown channel")?.clone();
let old_key = ch.key.ok_or("we hold no key for this channel, so we cannot rotate it")?;
let new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
let owner = community.owner()?;
let owner_hex = owner.to_hex();
let removed_hex = removed.to_hex();
let mut recipients: Vec<PublicKey> = vec![my_pk];
if owner != my_pk {
recipients.push(owner);
}
for g in &roster.grants {
if g.member == removed_hex || g.member == owner_hex {
continue;
}
if !g.role_ids.iter().any(|id| access_ids.contains(id)) {
continue;
}
if let Ok(pk) = PublicKey::parse(&g.member) {
if !recipients.contains(&pk) {
recipients.push(pk);
}
}
}
let new_key = mint_or_reuse_rotation_key(&cid_hex, &chan_hex, new_epoch.0)?;
let prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
let mut blobs = Vec::with_capacity(recipients.len());
for r in &recipients {
blobs.push(
rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(*channel_id), new_epoch, &new_key)
.await
.map_err(|e| e.to_string())?,
);
}
let group = channel_rekey_group_key(&community.community_root, channel_id, new_epoch);
let at_secs = now_ms() / 1000;
let chunks = rekey::build_rekey_chunks(&signer, my_pk, &group, RekeyScope::Channel(*channel_id), new_epoch, ch.epoch, &prev_commit, &blobs, at_secs, my_authority_citation(community, &my_pk).as_ref())
.await
.map_err(|e| e.to_string())?;
if !session.is_valid() {
return Err("account changed during channel rekey".to_string());
}
for c in &chunks {
transport.publish_durable(c, &community.relays).await?;
}
if !session.is_valid() {
return Err("account changed during channel rekey".to_string());
}
if crate::db::community::community_protocol(community.id())?.is_none() {
return Err("community removed during channel rekey".to_string());
}
crate::db::community::advance_channel_epoch(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
crate::db::community::store_epoch_key(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
{
let mut rotated = community.clone();
if let Some(c) = rotated.channels.iter_mut().find(|c| c.id == *channel_id) {
c.key = Some(new_key);
c.epoch = new_epoch;
}
match read_channel_pins(&rotated, channel_id) {
Ok(read) if !read.sealed && !read.pins.is_empty() => {
let entries: Vec<super::pins::PinEntry> =
read.pins.iter().map(|p| p.entry.clone()).collect();
if let Some(ch2) = rotated.channel(channel_id).cloned() {
if let Err(e) = publish_pin_list(transport, &rotated, &session, &ch2, &entries).await {
crate::log_warn!("[pins] rotation reseal failed (a curator's next edit heals): {e}");
} else {
crate::log_info!("[pins] resealed {} pin(s) under epoch {}", entries.len(), new_epoch.0);
}
}
}
Ok(read) if read.sealed => {
crate::log_warn!("[pins] rotating a channel whose pin list we cannot read; reseal skipped");
}
_ => {}
}
}
Ok(())
}
pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
let session = SessionGuard::capture();
let lock = super::realtime::follow_lock(community.id());
let _guard = lock.lock().await;
let my_pk = me_pk()?;
ensure_channel_manager(community, &my_pk)?;
let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
});
meta.deleted = Some(true);
let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
if !session.is_valid() {
return Err("account changed during channel delete".to_string());
}
let mut updated = community.clone();
updated.channels.retain(|c| c.id.0 != channel_id.0);
crate::db::community::save_community_v2(&updated)?;
Ok(())
}
pub async fn follow_control<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
session: &SessionGuard,
) -> Result<Option<CommunityV2>, String> {
community.owner()?; let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
.into_iter()
.filter(|(_, f)| f.0 == community.root_epoch.0)
.map(|(entity, f)| (entity, (f.1, f.2, f.3)))
.collect();
let mut editions: Vec<ParsedEdition> = Vec::new();
let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
let mut oldest: Option<u64> = None;
let mut until: Option<u64> = None;
let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false, pins_persist: Vec::new() };
let mut authority = AuthoritySet::owner_only();
let mut truncated = true;
for _ in 0..FOLLOW_MAX_PAGES {
let query = Query {
kinds: vec![stream::KIND_WRAP],
authors: vec![control.pk_hex()],
until,
limit: Some(FOLLOW_PAGE),
evidence: crate::community::transport::Evidence::Quorum,
..Default::default()
};
let wraps = transport.fetch(&query, &community.relays).await?;
let mut fresh = 0usize;
for w in &wraps {
if !seen_wraps.insert(w.id) {
continue;
}
fresh += 1;
let at = w.created_at.as_secs();
if oldest.is_none_or(|o| at < o) {
oldest = Some(at);
}
if let Ok((ed, _)) = control::open_control_edition(w, &control) {
if seen.insert(ed.inner_id) {
editions.push(ed);
}
}
}
authority = fold_authority(community, &editions, &floors);
fold = apply_control_fold(community, &editions, &floors, &authority);
if !(fold.gapped || authority.gapped) {
truncated = false; break;
}
if fresh == 0 {
truncated = wraps.len() >= FOLLOW_PAGE;
break;
}
until = oldest;
}
if !session.is_valid() {
return Err("account changed during control follow".to_string());
}
if crate::db::community::community_protocol(community.id())?.is_none() {
return Ok(None);
}
for h in fold.heads.iter().chain(authority.heads.iter()) {
crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, community.root_epoch.0)?;
crate::db::community::converge_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, community.root_epoch.0)?;
}
let mut authority_changed = false;
let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
if let Some((banned, version)) = &authority.banlist_persist {
let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
let mut after = banned.clone();
before.sort();
after.sort();
authority_changed |= before != after;
}
let newest_roster_at: i64 = editions
.iter()
.filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
.map(|e| e.created_at as i64)
.max()
.unwrap_or(0);
let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
&& stored.grants.iter().all(|g| {
crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
!floors.contains_key(&eid) || head_ents.contains(eid.as_str())
})
});
if !truncated && !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
authority_changed |= stored != authority.roles;
crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
}
if !truncated && !authority.gapped && !fold.gapped {
if let Ok(owner) = community.owner() {
let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
let live = flatten_link_sets(&sets);
let mut before = crate::db::community::get_community_invite_registry(&cid_hex).unwrap_or_default();
before.sort();
if before != live {
crate::db::community::set_community_invite_registry(&cid_hex, &live)?;
authority_changed = true;
}
crate::db::community::replace_invite_link_sets(&cid_hex, &sets)?;
}
}
for (channel_hex, content, version, author_npub, created_at) in &fold.pins_persist {
match crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64) {
Ok(true) => {
crate::log_info!("[pins] fold adopted v{} for channel {}", version, &channel_hex[..12]);
crate::emit_event(
"community_pins_updated",
&serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
);
note_pins_modified(channel_hex, *version, author_npub, *created_at).await;
}
Ok(false) => {}
Err(e) => crate::log_warn!("[pins] fold persist failed: {e}"),
}
}
if authority_changed {
crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
}
{
static UPGRADED: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
let first = UPGRADED
.lock()
.map(|mut set| set.get_or_insert_with(Default::default).insert(cid_hex.clone()))
.unwrap_or(false);
if first {
let _ = upgrade_admin_role_pin_bit(transport, community).await;
}
}
match fold.updated {
Some(u) => {
crate::db::community::save_community_v2(&u)?;
Ok(Some(u))
}
None => Ok(None),
}
}
const FOLLOW_MAX_PAGES: usize = 32;
const FOLLOW_PAGE: usize = 500;
const COMPACT_MAX_PAGES: usize = 512;
#[derive(Clone)]
struct FoldedHead {
entity_hex: String,
version: u64,
self_hash: [u8; 32],
inner_id: [u8; 32],
}
struct ControlFold {
updated: Option<CommunityV2>,
heads: Vec<FoldedHead>,
gapped: bool,
pins_persist: Vec<(String, String, u64, String, u64)>,
}
type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
use crate::community::roles::Permissions;
use std::collections::BTreeMap;
let owner_hex = community.owner().ok().map(|o| o.to_hex());
let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
for e in editions {
groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
}
let mut out = community.clone();
let mut changed = false;
let mut heads = Vec::new();
let mut gapped = false;
let mut pins_persist = Vec::new();
let pins_by_eid: std::collections::HashMap<[u8; 32], String> = community
.channels
.iter()
.map(|ch| (super::derive::pins_locator(community.id(), &ch.id), crate::simd::hex::bytes_to_hex_32(&ch.id.0)))
.collect();
for ((vsk_code, eid), group) in &groups {
let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
let pins_channel = (vsk_code == vsk::PINS).then(|| pins_by_eid.get(eid)).flatten();
if !is_meta && !is_channel && pins_channel.is_none() {
continue;
}
let required = if is_meta {
Permissions::MANAGE_METADATA
} else if is_channel {
Permissions::MANAGE_CHANNELS
} else {
Permissions::PIN_MESSAGES
};
let authed: Vec<&ParsedEdition> = group
.iter()
.copied()
.filter(|e| {
let author = e.author.to_hex();
!authority.banned.contains(&author)
&& authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
&& citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
})
.collect();
if authed.is_empty() {
continue;
}
let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
gapped |= entity_gapped;
let Some(hi) = hi else { continue };
let head = authed[hi];
heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
if is_meta {
if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
changed |= apply_community_metadata(&mut out, meta);
}
} else if is_channel {
if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
}
} else if let Some(channel_hex) = pins_channel {
use nostr_sdk::prelude::ToBech32;
let author_npub = head
.author
.to_bech32()
.unwrap_or_else(|_| head.author.to_hex());
pins_persist.push((channel_hex.clone(), head.content.clone(), head.version, author_npub, head.created_at));
}
}
ControlFold { updated: changed.then_some(out), heads, gapped, pins_persist }
}
fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
let floor_v = floor.map(|f| f.0).unwrap_or(0);
if floor_v == 0 {
return (version::bootstrap_head(fold_eds, 0), false);
}
let floor_hash = floor.map(|f| &f.1);
let held_inner = floor.and_then(|f| f.2);
let result = version::fold(fold_eds, floor_v, floor_hash);
if result.anchored {
return (result.head, result.gap); }
if result.head.is_none() && !result.gap {
return (None, false); }
let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
let win_hash = match fork {
Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
_ => return (None, true), };
let re = version::fold(fold_eds, floor_v, Some(&win_hash));
if !re.anchored {
return (None, true);
}
(re.head, re.gap)
}
struct AuthoritySet {
roles: crate::community::roles::CommunityRoles,
banned: std::collections::BTreeSet<String>,
heads: Vec<FoldedHead>,
gapped: bool,
banlist_persist: Option<(Vec<String>, u64)>,
banned_at: std::collections::BTreeMap<String, u64>,
}
impl AuthoritySet {
fn owner_only() -> Self {
AuthoritySet {
roles: Default::default(),
banned: Default::default(),
heads: vec![],
gapped: false,
banlist_persist: None,
banned_at: Default::default(),
}
}
}
fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
use crate::community::roles::Permissions;
use std::collections::BTreeMap;
let cid = community.id();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
let owner = community.owner().ok();
let owner_hex = owner.map(|o| o.to_hex());
let banlist_eid = super::derive::banlist_locator(cid);
let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
for e in editions {
if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
groups.entry(e.entity_id).or_default().push(e);
}
}
let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
let mut gapped = false;
for (eid, group) in &groups {
if *eid == banlist_eid {
continue;
}
let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
gapped |= entity_gapped;
let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
for p in group {
if p.version < floor_v {
continue;
}
let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
match p.vsk.as_str() {
vsk::ROLE => {
if let Some(role) = super::roles::parse_role_content(&p.content) {
if role.role_id == entity_hex && role.position != 0 {
role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
}
}
}
vsk::GRANT => {
if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
if super::derive::grant_locator(cid, &member) == *eid {
grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
}
}
}
}
_ => {}
}
}
}
for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
}
let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty);
let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
let banlist_authored: Vec<&ParsedEdition> = groups
.get(&banlist_eid)
.map(|g| {
g.iter()
.copied()
.filter(|e| {
let ah = e.author.to_hex();
!banned_authors.contains(ah.as_str())
&& prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
&& citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
})
.collect()
})
.unwrap_or_default();
let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
for p in &banlist_authored {
for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
if owner_hex.as_deref() == Some(t.as_str()) {
continue;
}
let slot = banned_at.entry(t).or_insert(0);
*slot = (*slot).max(p.created_at);
}
}
let mut banlist_persist: Option<(Vec<String>, u64)> = None;
let mut banlist_head: Option<FoldedHead> = None;
let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
persisted_banned.into_iter().collect()
} else {
let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
gapped |= g;
match hi {
Some(hi) => {
let head = banlist_authored[hi];
let ah = head.author.to_hex();
let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
.unwrap_or_default()
.into_iter()
.filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
.take(super::roles::MAX_BANLIST)
.collect();
banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
banlist_persist = Some((list.clone(), head.version));
list.into_iter().collect()
}
None => persisted_banned.into_iter().collect(),
}
};
let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned);
if let Some(bh) = banlist_head {
heads.push(bh);
}
if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
authorized.grants.retain(|g| !g.role_ids.is_empty());
}
AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
}
struct AuthorityCand {
role: Option<crate::community::roles::Role>,
grant: Option<crate::community::roles::MemberGrant>,
author: PublicKey,
head: FoldedHead,
citation: Option<crate::community::edition::AuthorityCitation>,
}
fn citation_ok_in_fold(
cid: &crate::community::CommunityId,
heads: &[FoldedHead],
owner_hex: Option<&str>,
author: &PublicKey,
citation: Option<&crate::community::edition::AuthorityCitation>,
) -> bool {
let actor_hex = author.to_hex();
if owner_hex == Some(actor_hex.as_str()) {
return true;
}
let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
let as_entity: Vec<crate::community::roster::EntityHead> = heads
.iter()
.map(|h| crate::community::roster::EntityHead {
entity_hex: h.entity_hex.clone(),
version: h.version,
self_hash: h.self_hash,
inner_id: h.inner_id,
citation: None,
})
.collect();
crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
}
fn select_authorized(
cid: &crate::community::CommunityId,
role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
owner_hex: Option<&str>,
excluded: &std::collections::BTreeSet<String>,
) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
use crate::community::roles::{CommunityRoles, Permissions};
let mut accepted = CommunityRoles::default();
let mut heads: Vec<FoldedHead> = Vec::new();
let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
for _ in 0..bound {
let mut next = CommunityRoles::default();
let mut next_heads: Vec<FoldedHead> = Vec::new();
for cands in role_cands.values() {
let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
let mut standing: Option<u32> = None;
let mut i = cands.len();
while i > 0 {
let hi = i;
let ver = cands[i - 1].head.version;
while i > 0 && cands[i - 1].head.version == ver {
i -= 1;
}
for c in cands[i..hi].iter().rev() {
let Some(role) = &c.role else { continue };
let ah = c.author.to_hex();
if excluded.contains(&ah) || role.position == 0 {
continue;
}
if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
continue;
}
if let Some(prev) = standing {
if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
continue;
}
}
if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
continue;
}
admissible.insert(c.head.self_hash);
standing = Some(role.position);
break;
}
}
for c in cands {
let Some(role) = &c.role else { continue };
if !admissible.contains(&c.head.self_hash) {
continue;
}
next.roles.push(role.clone());
next_heads.push(c.head.clone());
break; }
}
for cands in grant_cands.values() {
for c in cands {
let Some(grant) = &c.grant else { continue };
let ah = c.author.to_hex();
if excluded.contains(&ah) || excluded.contains(&grant.member) {
continue;
}
let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
let Some(positions) = positions else { continue };
if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
continue;
}
if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
&& accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
{
next_heads.push(c.head.clone());
if !grant.role_ids.is_empty() {
next.grants.push(grant.clone());
}
break;
}
}
}
let converged = next.roles == accepted.roles && next.grants == accepted.grants;
accepted = next;
heads = next_heads;
if converged {
break;
}
}
(accepted, heads)
}
fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
let mut changed = false;
if out.name != meta.name {
out.name = meta.name;
changed = true;
}
if out.description != meta.description {
out.description = meta.description;
changed = true;
}
if out.icon != meta.icon {
out.icon = meta.icon;
changed = true;
}
if out.banner != meta.banner {
out.banner = meta.banner;
changed = true;
}
if out.meta_custom != meta.custom {
out.meta_custom = meta.custom;
changed = true;
}
if out.meta_extra != meta.extra {
out.meta_extra = meta.extra;
changed = true;
}
let relays = crate::community::cap_relays(meta.relays);
if !relays.is_empty() && out.relays != relays {
out.relays = relays;
changed = true;
}
changed
}
fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
let deleted = meta.deleted.unwrap_or(false);
if deleted {
let before = out.channels.len();
out.channels.retain(|c| c.id.0 != id.0);
return out.channels.len() != before;
}
match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
Some(existing) => {
let mut changed = false;
if existing.name != meta.name {
existing.name = meta.name;
changed = true;
}
if existing.voice != meta.voice {
existing.voice = meta.voice;
changed = true;
}
if existing.meta_custom != meta.custom {
existing.meta_custom = meta.custom;
changed = true;
}
if existing.meta_extra != meta.extra {
existing.meta_extra = meta.extra;
changed = true;
}
if !meta.private && (existing.private || existing.key.is_some()) {
existing.private = false;
existing.key = None;
changed = true;
}
changed
}
None if !meta.private => {
out.channels.push(ChannelV2 {
id,
name: meta.name,
private: false,
key: None,
epoch: out.root_epoch,
voice: meta.voice,
meta_custom: meta.custom,
meta_extra: meta.extra,
});
true
}
None => {
out.channels.push(ChannelV2 {
id,
name: meta.name,
private: true,
key: None,
epoch: Epoch(0),
voice: meta.voice,
meta_custom: meta.custom,
meta_extra: meta.extra,
});
true
}
}
}
pub struct RekeyFollow {
pub updated: Option<CommunityV2>,
pub self_removed: bool,
pub dissolved: bool,
}
const MAX_ADDRESSING_ROOTS: usize = 8;
pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
let mut roots: Vec<[u8; 32]> = vec![cur_root];
let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
.unwrap_or_default();
archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
for (_, r) in archived {
if !roots.contains(&r) {
roots.push(r);
}
}
roots.truncate(MAX_ADDRESSING_ROOTS);
roots
}
#[cfg(debug_assertions)]
pub async fn debug_explain_base_rekey<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
) -> Result<serde_json::Value, String> {
let my_xonly = me_pk()?.to_bytes();
let owner = community.owner()?;
let owner_hex = owner.to_hex();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
let held_epoch = community.root_epoch;
let held_key = community.community_root;
let next = Epoch(held_epoch.0.saturating_add(1));
let group = base_rekey_group_key(&held_key, community.id(), next);
let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
let rotations = rekey::collect_rotations(&chunks);
let reports: Vec<serde_json::Value> = rotations
.iter()
.map(|r| {
let rotator_is_owner = r.rotator == owner;
let rotator_authorized = rotator_is_owner
|| (!banned.contains(&r.rotator.to_hex())
&& roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
let epoch_ok = r.new_epoch.0 == next.0;
let complete = r.is_complete();
let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
let owner_kept = r.rotator == owner
|| rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
let verdict = if !rotator_authorized {
"REJECTED: rotator holds no BAN authority in the folded roster"
} else if !scope_ok {
"REJECTED: scope is not Root"
} else if !epoch_ok {
"REJECTED: new_epoch != held+1"
} else if !complete {
"WAIT: rotation incomplete (missing chunk) — never concludes removal"
} else if continuity != "Extends" {
"REJECTED: continuity does not extend my held root (FORK/GAP)"
} else if has_my_blob {
"ADOPT: authorized + complete + continuous + my blob present"
} else {
"REMOVED: complete authorized rotation with no blob for me"
};
serde_json::json!({
"rotator": r.rotator.to_hex(),
"rotator_is_recorded_owner": rotator_is_owner,
"rotator_authorized_ban": rotator_authorized,
"scope_is_root": scope_ok,
"new_epoch": r.new_epoch.0,
"prev_epoch": r.prev_epoch.0,
"declared_chunks": r.declared_chunks,
"held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
"is_complete": complete,
"continuity_vs_held_root": continuity,
"my_blob_present": has_my_blob,
"owner_kept": owner_kept,
"blob_count": r.blobs.len(),
"verdict": verdict,
})
})
.collect();
Ok(serde_json::json!({
"recorded_owner": owner.to_hex(),
"held_root_epoch": held_epoch.0,
"probing_next_epoch": next.0,
"base_plane_pk": group.pk_hex(),
"raw_chunks_parsed": chunks.len(),
"rotations_found": rotations.len(),
"rotations": reports,
}))
}
pub async fn follow_rekeys<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
session: &SessionGuard,
) -> Result<RekeyFollow, String> {
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
}
if is_dissolved(transport, community).await {
if session.is_valid() {
let _ = crate::db::community::set_community_dissolved(&cid_hex);
}
return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
}
let signer = crate::signer::active_signer()?;
let my_pk = me_pk()?;
let my_xonly = my_pk.to_bytes();
let owner = community.owner()?;
let owner_hex = owner.to_hex();
let mut cur = community.clone();
let mut changed = false;
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
let me_hex = my_pk.to_hex();
let cited_ok = |rot: &rekey::Rotation| -> bool {
citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
};
let channel_rotator_ok = |rotator: &PublicKey| -> bool {
if *rotator == owner {
return true;
}
let rh = rotator.to_hex();
!banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
};
let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
if *rotator == owner {
return true;
}
let rh = rotator.to_hex();
!banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
};
let base_rotator_ok = |rotator: &PublicKey| -> bool {
if *rotator == owner {
return true;
}
let rh = rotator.to_hex();
!banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
};
let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
if *rotator == owner {
return true;
}
let rh = rotator.to_hex();
!banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
};
const MAX_STEPS: usize = 128;
for _ in 0..MAX_STEPS {
let mut advanced = false;
let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
for cid in channel_ids {
let (held_key, held_epoch) = match cur.channel(&cid) {
Some(ch) => (ch.key, ch.epoch),
None => continue,
};
let next = Epoch(held_epoch.0.saturating_add(1));
let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
for (ri, root) in addressing_roots.iter().enumerate() {
let group = channel_rekey_group_key(root, &cid, next);
let chunks = match fetch_rekey_chunks(transport, &cur.relays, &group).await {
Ok(c) => c,
Err(e) => {
crate::log_warn!(
"[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
&cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
);
return Err(e);
}
};
if chunks.is_empty() {
continue;
}
crate::log_debug!(
"[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
&cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
);
batches.push((chunks, held_key.map(|k| (held_epoch, k))));
}
match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
Advance::Adopt { new_key } => {
if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
ch.key = Some(new_key);
ch.epoch = next;
}
crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&cid.0), next.0, &new_key) {
crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
}
advanced = true;
changed = true;
}
Advance::Removed => {
match held_key {
Some(_) => {
cur.channels.retain(|c| c.id.0 != cid.0);
}
None => {
if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
ch.epoch = next;
}
}
}
advanced = true;
changed = true;
}
Advance::Stay => {}
}
}
{
let held_epoch = cur.root_epoch;
let held_key = cur.community_root;
let next = Epoch(held_epoch.0.saturating_add(1));
let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
let batches = vec![(chunks, Some((held_epoch, held_key)))];
let base_admissible = |r: &rekey::Rotation| -> bool {
if r.rotator == owner {
return true; }
if !cited_ok(r) {
return false;
}
let rotator_hex = r.rotator.to_hex();
let has_blob = |xonly: &[u8; 32]| {
rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
};
if !has_blob(&owner.to_bytes()) {
return false;
}
for g in &roster.grants {
if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
continue; }
if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
if let Ok(pk) = PublicKey::from_hex(&g.member) {
if !has_blob(&pk.to_bytes()) {
return false; }
}
}
}
true
};
match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
Advance::Adopt { new_key } => {
cur.community_root = new_key;
cur.root_epoch = next;
if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
}
advanced = true;
changed = true;
}
Advance::Removed => {
if !session.is_valid() {
return Err("account changed during rekey follow".to_string());
}
return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
}
Advance::Stay => {}
}
}
if !advanced {
break;
}
}
if !changed {
return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
}
if !session.is_valid() {
return Err("account changed during rekey follow".to_string());
}
if crate::db::community::community_protocol(community.id())?.is_none() {
return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
}
crate::db::community::save_community_v2(&cur)?;
let _ = refresh_public_links(transport, &cur).await;
Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
}
enum Advance {
Adopt { new_key: [u8; 32] },
Removed,
Stay,
}
async fn fetch_rekey_chunks<T: Transport + ?Sized>(
transport: &T,
relays: &[String],
group: &GroupKey,
) -> Result<Vec<rekey::RekeyChunk>, String> {
const REKEY_PAGE: usize = 200;
const REKEY_MAX_PAGES: usize = 6;
let mut out = Vec::new();
let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
let mut until: Option<u64> = None;
let mut oldest: Option<u64> = None;
for _ in 0..REKEY_MAX_PAGES {
let query = Query {
kinds: vec![stream::KIND_WRAP],
authors: vec![group.pk_hex()],
until,
limit: Some(REKEY_PAGE),
..Default::default()
};
let wraps = transport.fetch_plane(group.keys(), &query, relays).await?;
let mut fresh = 0usize;
for w in &wraps {
if !seen.insert(w.id) {
continue;
}
fresh += 1;
let at = w.created_at.as_secs();
if oldest.is_none_or(|o| at < o) {
oldest = Some(at);
}
if let Ok(opened) = stream::open_wrap(w, group) {
if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
out.push(chunk);
}
}
}
if fresh == 0 || wraps.len() < REKEY_PAGE {
break;
}
match oldest {
Some(o) if o > 0 => until = Some(o),
_ => break,
}
}
Ok(out)
}
async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
scope: RekeyScope,
rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
signer: &S,
my_xonly: &[u8; 32],
next_epoch: Epoch,
) -> Advance {
let mut winners: Vec<[u8; 32]> = Vec::new();
let mut saw_complete_candidate = false;
let mut saw_outranking_candidate = false;
let keyed = batches.iter().any(|(_, held)| held.is_some());
for (chunks, held) in batches {
let rotations = rekey::collect_rotations(chunks);
for r in &rotations {
if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
continue;
}
if let Some((held_epoch, held_key)) = held {
if r.continuity(*held_epoch, held_key) != Continuity::Extends {
continue;
}
}
if !admissible(r) {
continue;
}
saw_complete_candidate = true;
saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
if let Ok(k) = rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob).await {
winners.push(k);
}
}
}
}
if !winners.is_empty() {
let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
return Advance::Adopt { new_key: winners[idx] };
}
if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
Advance::Removed
} else {
Advance::Stay
}
}
#[derive(Debug, serde::Serialize)]
pub struct ChannelPins {
pub pins: Vec<super::pins::VerifiedPin>,
pub sealed: bool,
pub version: u64,
}
fn channel_conv_key_at(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Option<[u8; 32]> {
let ikm = channel_conv_ikm(community, ch, epoch).ok()?;
if ch.private && ikm == community.community_root {
return None;
}
let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
group.conv_key().as_bytes().try_into().ok()
}
pub fn read_channel_pins(community: &CommunityV2, channel_id: &ChannelId) -> Result<ChannelPins, String> {
let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
let Some((content, version)) = crate::db::community::get_community_pins(&cid_hex, &ch_hex)? else {
return Ok(ChannelPins { pins: Vec::new(), sealed: false, version: 0 });
};
let read = super::pins::read_pin_list(&content, |epoch| channel_conv_key_at(community, ch, epoch));
let pins = read
.entries
.iter()
.filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
.collect();
Ok(ChannelPins { pins, sealed: read.sealed, version: version.max(0) as u64 })
}
async fn publish_pin_list<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
session: &SessionGuard,
ch: &ChannelV2,
entries: &[super::pins::PinEntry],
) -> Result<(), String> {
let content = if ch.private {
let key = ch.key.ok_or("this private channel's key has not arrived yet")?;
let group = channel_group_key(&key, &ch.id, ch.epoch);
super::pins::serialize_sealed_pin_list(entries, group.conv_key(), ch.epoch.0)?
} else {
super::pins::serialize_public_pin_list(entries)?
};
let eid = super::derive::pins_locator(community.id(), &ch.id);
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
Some((v, _)) => v + 1,
None => 1,
};
crate::log_info!("[pins] publishing v{} with {} entries for channel {}", version, entries.len(), &ch_hex[..12]);
publish_control_edition(transport, community, session, vsk::PINS, &eid, &content).await?;
if session.is_valid() {
let _ = crate::db::community::set_community_pins(&cid_hex, &ch_hex, &content, version as i64);
crate::emit_event(
"community_pins_updated",
&serde_json::json!({ "community_id": cid_hex, "channel_id": ch_hex }),
);
if let Ok(me) = me_pk() {
use nostr_sdk::prelude::ToBech32;
let me_npub = me.to_bech32().unwrap_or_else(|_| me.to_hex());
note_pins_modified(&ch_hex, version, &me_npub, now_ms() / 1000).await;
}
}
Ok(())
}
async fn note_pins_modified(channel_hex: &str, version: u64, actor_npub: &str, at_secs: u64) {
let event_id = format!("pins-mod-{}-v{}", &channel_hex[..16], version);
let inserted = crate::db::events::save_system_event_at(
&event_id,
channel_hex,
crate::stored_event::SystemEventType::PinsModified,
actor_npub,
None,
at_secs,
None,
None,
)
.await
.unwrap_or(false);
if inserted {
crate::emit_event(
"system_event",
&serde_json::json!({
"conversation_id": channel_hex,
"event_id": event_id,
"event_type": crate::stored_event::SystemEventType::PinsModified.as_u8(),
"member_pubkey": actor_npub,
}),
);
}
}
fn writable_pin_entries(community: &CommunityV2, channel_id: &ChannelId) -> Result<Vec<super::pins::PinEntry>, String> {
let current = read_channel_pins(community, channel_id)?;
if current.sealed {
return Err("this channel's pins are sealed under a key you don't hold; pinning would erase them".to_string());
}
Ok(current.pins.into_iter().map(|p| p.entry).collect())
}
pub async fn pin_message<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
rumor_id_hex: &str,
) -> Result<(), String> {
let session = SessionGuard::capture();
let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
let mut entries = writable_pin_entries(community, channel_id)?;
if entries.len() >= super::pins::PIN_MAX_ENTRIES {
return Err(format!("this channel already holds {} pins; unpin one first", super::pins::PIN_MAX_ENTRIES));
}
if entries
.iter()
.filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
.any(|v| v.rumor_id == rumor_id_hex)
{
return Ok(());
}
let (wrap_id, _tags) = crate::db::events::get_event_wrap_context(rumor_id_hex)?
.ok_or("message not found in this device's history")?;
let wrap_id = wrap_id.ok_or("this message's original wrap id was not recorded")?;
let wraps = transport
.fetch(
&Query {
ids: vec![wrap_id.clone()],
kinds: vec![super::stream::KIND_WRAP],
limit: Some(1),
evidence: crate::community::transport::Evidence::Full,
..Default::default()
},
&community.relays,
)
.await?;
let wrap = wraps
.iter()
.find(|w| w.id.to_hex() == wrap_id)
.ok_or("the message's wrap is no longer served by this community's relays")?;
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let mut coords: Vec<(u64, [u8; 32])> = Vec::new();
if ch.private {
if let Some(k) = ch.key {
coords.push((ch.epoch.0, k));
}
let held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
coords.extend(held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (ep.0, k)));
} else {
coords.push((community.root_epoch.0, community.community_root));
let held = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
coords.extend(held.into_iter().map(|(ep, k)| (ep.0, k)));
}
coords.dedup();
let mut found: Option<(super::stream::OpenedStream, [u8; 32])> = None;
for (epoch, ikm) in coords {
let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
if let Ok(super::chat::ChatEvent::Message { opened, .. }) =
super::chat::open_chat_event(wrap, &group, channel_id, Epoch(epoch))
{
if opened.rumor_id.to_hex() == rumor_id_hex {
let conv: [u8; 32] = group
.conv_key()
.as_bytes()
.try_into()
.map_err(|_| "conversation key size".to_string())?;
found = Some((opened, conv));
break;
}
}
}
let Some((opened, conv_key)) = found else {
return Err("that message is from a key epoch this device no longer holds".to_string());
};
let entry = super::pins::build_pin_entry(&opened, &conv_key, &ch_hex).map_err(|e| match e {
super::pins::PinBuildFailure::NotEncrypted => "this message's seal form cannot be pinned".to_string(),
super::pins::PinBuildFailure::BadPayload => "that message is from a key epoch this device no longer holds".to_string(),
super::pins::PinBuildFailure::Unverifiable => "this message's proof did not verify".to_string(),
})?;
entries.push(entry);
if !session.is_valid() {
return Err("account changed before the pin was published".to_string());
}
publish_pin_list(transport, community, &session, &ch, &entries).await
}
fn channel_conv_ikm(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Result<[u8; 32], String> {
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
if ch.private {
if ch.epoch.0 == epoch {
return ch.key.ok_or("this private channel's key has not arrived yet".to_string());
}
let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
crate::db::community::held_epoch_keys(&cid_hex, &ch_hex)
.unwrap_or_default()
.into_iter()
.find(|(ep, _)| ep.0 == epoch)
.map(|(_, k)| k)
.ok_or("that message is from a key epoch this device no longer holds".to_string())
} else if community.root_epoch.0 == epoch {
Ok(community.community_root)
} else {
crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
.unwrap_or_default()
.into_iter()
.find(|(ep, _)| ep.0 == epoch)
.map(|(_, k)| k)
.ok_or("that message is from a root epoch this device no longer holds".to_string())
}
}
pub async fn unpin_message<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
channel_id: &ChannelId,
rumor_id_hex: &str,
) -> Result<(), String> {
let session = SessionGuard::capture();
let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
let entries = writable_pin_entries(community, channel_id)?;
let kept: Vec<super::pins::PinEntry> = entries
.into_iter()
.filter(|e| {
super::pins::verify_pin_entry(e, &ch_hex)
.map(|v| v.rumor_id != rumor_id_hex)
.unwrap_or(true)
})
.collect();
publish_pin_list(transport, community, &session, &ch, &kept).await
}
pub(crate) fn spawn_pin_duty(channel_hex: &str, target_rumor_hex: &str, edit: Option<super::stream::OpenedStream>) {
let channel_hex = channel_hex.to_string();
let target = target_rumor_hex.to_string();
let session = SessionGuard::capture();
tokio::spawn(async move {
let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
let _ = run_pin_duty(&transport, &channel_hex, &target, edit, session).await;
});
}
async fn run_pin_duty<T: Transport + ?Sized>(
transport: &T,
channel_hex: &str,
target: &str,
edit: Option<super::stream::OpenedStream>,
session: SessionGuard,
) -> Result<(), String> {
use crate::community::roles::Permissions;
let Some(cid_hex) = crate::db::community::community_id_for_channel(channel_hex)? else {
return Ok(());
};
let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
let Some(community) = crate::db::community::load_community_v2(&cid)? else {
return Ok(());
};
let channel_id = ChannelId(crate::simd::hex::hex_to_bytes_32(channel_hex));
let read = read_channel_pins(&community, &channel_id)?;
if read.sealed {
return Ok(());
}
let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
return Ok(());
};
if let Some(ed) = &edit {
if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
return Ok(());
}
}
let me = me_pk()?;
let me_hex = me.to_hex();
let owner_hex = community.owner()?.to_hex();
let roster = crate::db::community::get_community_roles(&cid_hex)?;
if !roster.is_authorized(&me_hex, Some(&owner_hex), Permissions::PIN_MESSAGES) {
return Ok(());
}
if hit.author != me_hex {
let mut h: u32 = 0;
for b in me_hex.bytes().chain(target.bytes()) {
h = h.wrapping_mul(31).wrapping_add(u32::from(b));
}
tokio::time::sleep(std::time::Duration::from_secs(5 + u64::from(h % 21))).await;
if !session.is_valid() {
return Ok(());
}
}
let read = read_channel_pins(&community, &channel_id)?;
if read.sealed {
return Ok(());
}
let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
return Ok(());
};
let ch = community.channel(&channel_id).ok_or("no such channel")?.clone();
let entries: Vec<super::pins::PinEntry> = match &edit {
None => read
.pins
.iter()
.filter(|p| p.rumor_id != target)
.map(|p| p.entry.clone())
.collect(),
Some(ed) => {
if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
return Ok(());
}
let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
let mut bundle = None;
let mut epochs: Vec<u64> = vec![if ch.private { ch.epoch.0 } else { community.root_epoch.0 }];
let scope = if ch.private { ch_hex.clone() } else { crate::community::SERVER_ROOT_SCOPE_HEX.to_string() };
epochs.extend(
crate::db::community::held_epoch_keys(&cid_hex, &scope)
.unwrap_or_default()
.into_iter()
.map(|(ep, _)| ep.0),
);
epochs.dedup();
for epoch in epochs {
let Some(conv) = channel_conv_key_at(&community, &ch, epoch) else { continue };
if let Ok(b) = super::pins::build_pin_edit_bundle(ed, &conv, &hit.author, target, &ch_hex) {
bundle = Some(b);
break;
}
}
let Some(bundle) = bundle else { return Ok(()) };
read.pins
.iter()
.map(|p| {
let mut entry = p.entry.clone();
if p.rumor_id == target {
entry.edit = Some(bundle.clone());
}
entry
})
.collect()
}
};
if !session.is_valid() {
return Ok(());
}
crate::log_info!(
"[pins] duty {} for target {} in channel {}",
if edit.is_some() { "edit-refresh" } else { "omission" },
&target[..12],
&channel_hex[..12]
);
publish_pin_list(transport, &community, &session, &ch, &entries).await
}
pub async fn upgrade_admin_role_pin_bit<T: Transport + ?Sized>(
transport: &T,
community: &CommunityV2,
) -> Result<bool, String> {
use crate::community::roles::{Permissions, RoleScope};
let my_pk = me_pk()?;
if community.owner()? != my_pk {
return Ok(false);
}
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let roles = crate::db::community::get_community_roles(&cid_hex)?;
let Some(role) = roles.roles.iter().find(|r| {
matches!(r.scope, RoleScope::Server)
&& r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK)
&& !r.permissions.contains(Permissions::PIN_MESSAGES)
}) else {
return Ok(false);
};
let mut widened = role.clone();
widened.permissions.0 |= Permissions::PIN_MESSAGES;
set_role(transport, community, &widened).await?;
Ok(true)
}
#[cfg(test)]
mod tests {
use crate::ClientRelayExt;
use nostr_sdk::prelude::FinalizeEvent;
use super::super::super::transport::memory::MemoryRelay;
use super::*;
use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
fn account_name(n: u32) -> String {
const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
let mut acct = String::from("npub1");
let mut v = n as usize;
for _ in 0..58 {
acct.push(B[v % 32] as char);
v = v / 32 + 7;
}
acct
}
struct Actor {
keys: Keys,
account: String,
}
struct TestBed {
_tmp: tempfile::TempDir,
_guard: std::sync::MutexGuard<'static, ()>,
relay: MemoryRelay,
relays: Vec<String>,
}
impl TestBed {
fn new() -> (TestBed, Actor, Actor) {
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
crate::db::close_database();
crate::db::clear_id_caches();
let tmp = tempfile::tempdir().unwrap();
crate::db::set_app_data_dir(tmp.path().to_path_buf());
let mk = || {
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let account = account_name(n);
std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
crate::db::set_current_account(account.clone()).unwrap();
crate::db::init_database(&account).unwrap();
Actor { keys: Keys::generate(), account }
};
let owner = mk();
let member = mk();
let _ = crate::state::take_nostr_client();
let bed = TestBed {
_tmp: tmp,
_guard: guard,
relay: MemoryRelay::new(),
relays: vec!["wss://r".to_string()],
};
(bed, owner, member)
}
fn swap_to(&self, actor: &Actor) {
crate::state::bump_session_generation();
crate::db::set_current_account(actor.account.clone()).unwrap();
crate::db::init_database(&actor.account).unwrap();
crate::db::clear_id_caches();
crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
crate::state::set_my_public_key(actor.keys.public_key());
}
}
fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
crate::db::close_database();
crate::db::clear_id_caches();
let tmp = tempfile::tempdir().unwrap();
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let acct = account_name(n);
std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
crate::db::set_app_data_dir(tmp.path().to_path_buf());
crate::db::set_current_account(acct.clone()).unwrap();
crate::db::init_database(&acct).unwrap();
let _ = crate::state::take_nostr_client();
let owner = Keys::generate();
crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
crate::state::set_my_public_key(owner.public_key());
(tmp, guard, owner)
}
struct SwapMidFetch {
inner: MemoryRelay,
}
#[async_trait::async_trait]
impl Transport for SwapMidFetch {
async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
self.inner.publish(e, r).await
}
async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
self.inner.publish_durable(e, r).await
}
async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
let out = self.inner.fetch(q, r).await;
crate::state::bump_session_generation();
out
}
}
struct SwapMidPublish {
inner: MemoryRelay,
}
#[async_trait::async_trait]
impl Transport for SwapMidPublish {
async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
self.inner.publish(e, r).await
}
async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
let out = self.inner.publish_durable(e, r).await;
crate::state::bump_session_generation();
out
}
async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
self.inner.fetch(q, r).await
}
}
struct FixedFetch {
events: Vec<Event>,
}
#[async_trait::async_trait]
impl Transport for FixedFetch {
async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
Ok(())
}
async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
Ok(())
}
async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
Ok(self.events.clone())
}
}
async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
let q = Query {
kinds: vec![stream::KIND_WRAP],
p_tags: vec![me.to_hex()],
k_tags: vec!["3313".to_string()],
..Default::default()
};
relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
}
#[tokio::test]
async fn create_persists_and_reloads_a_v2_community() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let relays = vec!["wss://r".to_string()];
let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
assert!(created.identity.verify());
assert_eq!(created.owner().unwrap(), owner.public_key());
assert_eq!(created.channels.len(), 1);
assert_eq!(
crate::db::community::community_protocol(created.id()).unwrap(),
Some(crate::community::ConcordProtocol::V2)
);
let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
assert_eq!(loaded.name, "Vectorville");
assert_eq!(loaded.community_root, created.community_root);
assert_eq!(loaded.identity, created.identity);
assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
assert!(!loaded.channels[0].private);
assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
}
#[tokio::test]
async fn owner_sends_and_reads_back_a_message() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
let general = community.channels[0].id;
let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
assert_ne!(id1, id2);
let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
let texts: Vec<String> = page
.iter()
.filter_map(|f| match &f.event {
ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
_ => None,
})
.collect();
assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
}
#[tokio::test]
async fn a_second_member_reads_the_public_channel_from_the_root() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
let general = community.channels[0].id;
send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
let member_view = community.clone();
let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
assert_eq!(page.len(), 1);
assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
}
async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
fetch_channel(relay, community, channel, 100)
.await
.unwrap()
.iter()
.filter_map(|f| match &f.event {
ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
_ => None,
})
.collect()
}
#[tokio::test]
async fn direct_invite_full_loop_owner_and_member_converse() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
bed.swap_to(&member);
assert!(
crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
"the member does not hold the community before joining"
);
let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
assert_eq!(joined.id().0, community.id().0, "joined the same community");
assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
bed.swap_to(&owner);
assert_eq!(
texts_in(&bed.relay, &community, &general).await,
vec!["owner: welcome!", "member: thanks for the invite"],
"both actors' messages interleave in ms order on the shared channel"
);
let members = memberlist(&bed.relay, &community).await.unwrap();
assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
assert_eq!(members.len(), 2);
}
#[tokio::test]
async fn a_banned_member_is_refused_at_join_time() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
bed.swap_to(&member);
let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
assert!(err.contains("banned"), "refusal names the reason: {err}");
assert!(
crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
"a refused join persists nothing"
);
bed.swap_to(&owner);
set_banlist(&bed.relay, &community, &[]).await.unwrap();
bed.swap_to(&member);
let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
}
#[tokio::test]
async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
use crate::community::migration;
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
let jm = join_material(&v2);
bed.swap_to(&member);
let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
let v1_cid = v1.id.to_hex();
v1.owner_attestation = Some({
crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
.finalize(&owner.keys).unwrap().as_json()
});
crate::db::community::save_community(&v1).unwrap();
let v1_channel = v1.channels[0].id.to_hex();
let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
let signpost = migration::MigrationSignpost {
v2_community_id: v2_hex.clone(),
owner_xonly: owner.keys.public_key().to_hex(),
owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
relays: bed.relays.clone(),
name: "Guild".into(),
primary_channel: v1_channel.clone(),
root_epoch: 0,
};
let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
let _ = v1_channel;
assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
}
#[tokio::test]
async fn owner_wizard_then_member_migrate_and_stitch() {
use crate::community::migration;
let (bed, owner, member) = TestBed::new();
let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
bed.swap_to(&owner);
let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
let v1_cid = v1.id.to_hex();
let v1_channel = v1.channels[0].id.to_hex();
v1.owner_attestation = Some({
crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
.finalize(&owner.keys).unwrap().as_json()
});
crate::db::community::save_community(&v1).unwrap();
let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
"owner's own client flipped to v2");
assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
"owner channel stitched to v2");
bed.swap_to(&member);
let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
m_v1.id = v1.id;
m_v1.server_root_key = v1.server_root_key.clone();
m_v1.channels[0].id = v1.channels[0].id;
m_v1.owner_attestation = v1.owner_attestation.clone();
crate::db::community::save_community(&m_v1).unwrap();
crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
"the FOLD ITSELF flipped the member (auto-drive)");
assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
"member holds the v2 twin");
assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
}
#[tokio::test]
async fn wizard_publishes_the_twin_to_the_cross_device_list() {
use crate::community::migration;
let (bed, owner, _member) = TestBed::new();
let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
bed.swap_to(&owner);
let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
let v1_cid = v1.id.to_hex();
v1.owner_attestation = Some({
crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
.finalize(&owner.keys).unwrap().as_json()
});
crate::db::community::save_community(&v1).unwrap();
let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
let list = fetch_community_list(&bed.relay, &bed.relays).await.unwrap()
.expect("the wizard published a community list");
assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
assert!(
!list.tombstones.iter().any(|t| t.community_id == v1_cid),
"migration must not tombstone the v1 community"
);
}
#[tokio::test]
async fn wizard_refuses_while_a_drive_holds_the_claim() {
use crate::community::migration;
let (bed, owner, _member) = TestBed::new();
let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
bed.swap_to(&owner);
let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
let v1_cid = v1.id.to_hex();
v1.owner_attestation = Some({
crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
.finalize(&owner.keys).unwrap().as_json()
});
crate::db::community::save_community(&v1).unwrap();
migration::test_hold_drive_claim(&v1_cid);
let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
migration::test_release_drive_claim(&v1_cid);
let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
}
#[tokio::test]
async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
use crate::community::migration;
let (bed, owner, _member) = TestBed::new();
let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
let relay = std::sync::Arc::new(MemoryRelay::new());
bed.swap_to(&owner);
let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
let v1_cid = v1.id.to_hex();
let v1_channel = v1.channels[0].id.to_hex();
v1.owner_attestation = Some({
crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
.finalize(&owner.keys).unwrap().as_json()
});
crate::db::community::save_community(&v1).unwrap();
let twin = create_migration_twin(
&*relay, "Guild", bed.relays.clone(), None,
(v1.channels[0].id, "general".to_string()),
).await.unwrap();
let v2_id = twin.identity.community_id;
let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
let wizard = tokio::spawn({
let relay = relay.clone();
let v1 = v1.clone();
async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
});
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
assert!(
crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
"the fence must not be stamped while the follow lock is held"
);
drop(held);
let flipped = wizard.await.unwrap().unwrap();
assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
assert_eq!(
crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
Some(v2_hex.as_str()),
"the channel row is stitched to the twin, not pruned"
);
}
#[tokio::test]
async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
use crate::community::migration;
let (bed, owner, banned) = TestBed::new();
let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
bed.swap_to(&owner);
let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
let v1_cid = v1.id.to_hex();
v1.owner_attestation = Some({
crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
.finalize(&owner.keys).unwrap().as_json()
});
crate::db::community::save_community(&v1).unwrap();
crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
bed.swap_to(&banned);
let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
m_v1.id = v1.id;
m_v1.server_root_key = v1.server_root_key.clone();
m_v1.channels[0].id = v1.channels[0].id;
m_v1.owner_attestation = v1.owner_attestation.clone();
crate::db::community::save_community(&m_v1).unwrap();
crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
let payload = migration::parse_migration_payload(&raw).unwrap();
assert!(payload.m.is_some());
let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
assert!(
crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
"banned member never acquires the v2 twin"
);
}
#[tokio::test]
async fn wizard_resume_continues_on_the_same_twin() {
use crate::community::migration;
let (bed, owner, banned) = TestBed::new();
let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
bed.swap_to(&owner);
let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
let mut sibling = v1.channels[0].clone();
sibling.id = crate::community::ChannelId(crate::community::random_32());
sibling.name = "offtopic".into();
v1.channels.push(sibling.clone());
let v1_cid = v1.id.to_hex();
v1.owner_attestation = Some({
crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
.finalize(&owner.keys).unwrap().as_json()
});
crate::db::community::save_community(&v1).unwrap();
crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
assert_eq!(v2_hex, minted_hex, "no second twin was minted");
let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
assert_eq!(ledger_v2, minted_hex);
assert_eq!(phase, migration::PHASE_FLIPPED);
assert_eq!(
crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
Some(minted_hex.as_str()),
"sibling channel re-parented by the resumed run"
);
let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
let (_, _, wire_banlist) = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
.await
.map(|(c, h, b)| (c, h, b))
.unwrap();
assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
"the resumed banlist clone is folded from the twin's wire control plane");
crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
assert_eq!(healed, minted_hex);
let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
let v1b_cid = v1b.id.to_hex();
v1b.owner_attestation = Some({
crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
.finalize(&owner.keys).unwrap().as_json()
});
crate::db::community::save_community(&v1b).unwrap();
let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
}
#[tokio::test]
async fn birth_refound_seeds_an_explicit_roster() {
let (bed, owner, _m) = TestBed::new();
bed.swap_to(&owner);
let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
(ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
let ghost_a = Keys::generate().public_key();
let ghost_b = Keys::generate().public_key();
let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
let members = memberlist(&bed.relay, &rolled).await.unwrap();
assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
let (_, _, _banlist) = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
.expect("the epoch-1 twin verifies from its compacted control plane");
let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
}
#[tokio::test]
async fn birth_refound_ignores_a_banned_seed_entry() {
let (bed, owner, _m) = TestBed::new();
bed.swap_to(&owner);
let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
(ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
let good = Keys::generate().public_key();
let banned = Keys::generate();
set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
.expect("a banned seed entry is filtered, not a permanent verify-back wedge");
assert_eq!(rolled.root_epoch, Epoch(1));
let members = memberlist(&bed.relay, &rolled).await.unwrap();
assert!(members.contains(&good), "the non-banned seed lands");
assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
}
#[tokio::test]
async fn a_seeded_member_receives_a_later_refound_rekey() {
let (bed, owner, _m) = TestBed::new();
bed.swap_to(&owner);
let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
(ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
let ghost = Keys::generate();
let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
assert!(
memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
"a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
);
}
#[tokio::test]
async fn v1_admin_stays_admin_across_migration() {
use crate::community::migration;
use crate::community::roles::{CommunityRoles, MemberGrant, Role};
let (bed, owner, admin) = TestBed::new();
let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
bed.swap_to(&owner);
let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
let v1_cid = v1.id.to_hex();
v1.owner_attestation = Some({
crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
.finalize(&owner.keys).unwrap().as_json()
});
crate::db::community::save_community(&v1).unwrap();
let admin_role = Role::admin("a1".repeat(32));
let roles = CommunityRoles {
roles: vec![admin_role.clone()],
grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
};
crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
let authority = fetch_authority(&bed.relay, &twin).await;
assert!(
authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
"the v1 admin is an admin on the v2 twin"
);
assert!(
!authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
"a non-admin gains no authority"
);
}
#[tokio::test]
async fn an_unsealed_v1_that_was_migrated_away_still_heals() {
use crate::community::migration;
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
let v1_cid = v1.id.to_hex();
v1.owner_attestation = Some({
crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
.finalize(&owner.keys).unwrap().as_json()
});
crate::db::community::save_community(&v1).unwrap();
let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
bed.swap_to(&member);
crate::db::community::save_community(&v1).unwrap();
assert!(
!crate::db::community::get_community_dissolved(&v1_cid).unwrap(),
"precondition: the wedged device has NOT sealed its v1 row"
);
assert!(
crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
"an unsealed, migrated-away v1 must be probed"
);
migration::sweep_dissolved_for_migration(&bed.relay).await;
assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "sealed by the sweep");
assert_eq!(
crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(),
Some(v2_hex.as_str()),
"flipped to the same twin the first device produced"
);
assert!(
!crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
"and the sweep converges — no re-probing forever"
);
}
#[tokio::test]
async fn sweep_marks_checked_only_on_an_owner_tombstone() {
use crate::community::migration;
let (bed, owner, stranger) = TestBed::new();
bed.swap_to(&owner);
let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
let v1_cid = v1.id.to_hex();
v1.owner_attestation = Some({
crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
.finalize(&owner.keys).unwrap().as_json()
});
crate::db::community::save_community(&v1).unwrap();
let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
crate::db::community::set_community_dissolved(&v1_cid).unwrap();
migration::sweep_dissolved_for_migration(&bed.relay).await;
assert!(
crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
"a stranger-only probe must not converge the sweep"
);
let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
migration::sweep_dissolved_for_migration(&bed.relay).await;
assert!(
!crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
"an owner plain-dissolution converges the sweep"
);
}
#[tokio::test]
async fn wizard_preflight_gates_timelock_and_ownership() {
use crate::community::migration;
let (bed, owner, _member) = TestBed::new();
bed.swap_to(&owner);
let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
v1.owner_attestation = Some({
crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
.finalize(&owner.keys).unwrap().as_json()
});
crate::db::community::save_community(&v1).unwrap();
let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
assert!(err.contains("not unlocked"), "{err}");
assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
}
#[tokio::test]
async fn public_link_full_loop() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
assert!(link.url.starts_with("https://vectorapp.io/invite/"));
assert!(link.url.contains('#'), "the fragment carries the token");
bed.swap_to(&member);
let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
assert_eq!(joined.id().0, community.id().0);
assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
}
#[test]
fn bundle_of_snapshots_the_held_icon() {
let owner = Keys::generate();
let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
c.icon = Some(icon.clone());
let bundle = bundle_of(&c, BundleAudience::Link, None, None, None);
assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
}
#[test]
fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
let (_tmp, _guard, _owner) = init_test_db();
let cur_root = [9u8; 32];
let cid = crate::community::CommunityId([1u8; 32]);
let cid_hex = cid.to_hex();
let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
assert_eq!(roots[0], cur_root, "current root leads");
assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
for e in 3..20u64 {
crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
}
let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
}
#[tokio::test]
async fn public_link_preview_shows_live_name_and_icon_without_joining() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
let icon = control::ImageRef {
url: "https://blossom.example/soap".into(),
key: "k".into(),
nonce: "n".into(),
hash: "h".into(),
extra: Default::default(),
};
let mut meta = community.metadata();
meta.icon = Some(icon.clone());
edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
bed.swap_to(&member);
let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
assert_eq!(preview.name, "Soapbox");
assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
assert!(
crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
"previewing must not persist a membership"
);
}
#[tokio::test]
async fn a_previewed_join_reuses_the_verified_fold() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
bed.swap_to(&member);
let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
assert_eq!(joined.id().0, community.id().0);
assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
}
#[tokio::test]
async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
bed.swap_to(&member);
let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
let session = SessionGuard::capture();
assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the seed folds fresh events");
let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
assert!(cursor > 0, "the cursor advanced past zero");
let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
assert_eq!(stored, live, "stored fold == live fold after the seed");
assert!(stored.contains(&member.keys.public_key()));
assert!(sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty());
bed.swap_to(&owner);
kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
bed.swap_to(&member);
let session = SessionGuard::capture();
assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the kick lands incrementally");
assert!(
!stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
"an owner kick removes the member from the stored fold"
);
}
#[tokio::test]
async fn a_preview_then_revoke_still_refuses_the_join() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
bed.swap_to(&member);
let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
assert_eq!(p.name, "RevokeRace");
bed.swap_to(&owner);
let tombstone = invite::build_revocation(&link.link_signer).unwrap();
bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
bed.swap_to(&member);
let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
assert!(err.contains("revoked"), "got: {err}");
}
#[tokio::test]
async fn a_revoked_link_refuses_to_join() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
let tombstone = invite::build_revocation(&link.link_signer).unwrap();
bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
bed.swap_to(&member);
let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
}
#[tokio::test]
async fn an_expired_direct_invite_refuses_to_join() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
let inviter = owner.keys.clone();
let mut bundle = bundle_of(&community, BundleAudience::Link, Some(inviter.public_key()), Some(1_000), None);
bundle.expires_at = Some(1_000); let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
bed.relay.publish(&wrap, &bed.relays).await.unwrap();
bed.swap_to(&member);
let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
}
#[tokio::test]
async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
let tombstone = invite::build_revocation(&link.link_signer).unwrap();
let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
bed.swap_to(&member);
let err = accept_public_link(&union, &link.url).await.unwrap_err();
assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
}
#[test]
fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
let owner = Keys::generate();
let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
let hex = crate::simd::hex::bytes_to_hex_32;
let root = [0x11u8; 32];
let mut bundle = CommunityInvite {
community_id: hex(&identity.community_id.0),
owner: hex(&identity.owner_xonly),
owner_salt: hex(&identity.owner_salt),
community_root: hex(&root),
root_epoch: 0,
channels: vec![],
relays: vec!["wss://r".into()],
name: "X".into(),
icon: None,
expires_at: None,
creator_npub: None,
label: None,
extra: Default::default(),
};
bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
.map(|i| {
let mut id = [0u8; 32];
id[..8].copy_from_slice(&(i as u64).to_be_bytes());
invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
})
.collect();
assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
}
#[tokio::test]
async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
bed.swap_to(&member);
let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
assert!(
crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
"the aborted join wrote nothing to the (member) account DB"
);
}
#[tokio::test]
async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
let empty = MemoryRelay::new();
let members = memberlist(&empty, &community).await.unwrap();
assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
}
#[tokio::test]
async fn an_expiring_minted_invite_refuses_after_the_deadline() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
.await
.unwrap();
bed.swap_to(&member);
let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
assert!(
accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
"a minted expiring invite refuses past its deadline"
);
}
#[tokio::test]
async fn a_member_who_leaves_drops_from_the_memberlist() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
bed.swap_to(&member);
let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
leave_community(&bed.relay, &joined).await.unwrap();
bed.swap_to(&owner);
let members = memberlist(&bed.relay, &community).await.unwrap();
assert!(members.contains(&owner.keys.public_key()));
assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
}
#[tokio::test]
async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
bed.swap_to(&member);
assert!(
crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
"the owner's community must be invisible in the member's account DB"
);
assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
}
async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
let wraps = relay.fetch(&query, &community.relays).await.ok()?;
let mut head: Option<(u64, [u8; 32])> = None;
for w in &wraps {
if let Ok((ed, _)) = control::open_control_edition(w, &group) {
if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
head = Some((ed.version, ed.self_hash));
}
}
}
head.map(|(_, h)| h)
}
async fn cite_on_relay(
relay: &MemoryRelay,
community: &CommunityV2,
signer: &Keys,
) -> Option<crate::community::edition::AuthorityCitation> {
if community.owner().ok() == Some(signer.public_key()) {
return None;
}
let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
let wraps = relay.fetch(&query, &community.relays).await.ok()?;
let mut head: Option<(u64, [u8; 32])> = None;
for w in &wraps {
if let Ok((ed, _)) = control::open_control_edition(w, &group) {
if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
head = Some((ed.version, ed.self_hash));
}
}
}
head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
}
async fn publish_channel_edition(
relay: &MemoryRelay,
community: &CommunityV2,
signer: &Keys,
channel_id: &ChannelId,
name: &str,
private: bool,
version: u64,
deleted: bool,
) {
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
let content = serde_json::to_string(&meta).unwrap();
let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
relay.publish(&wrap, &community.relays).await.unwrap();
}
async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
}
async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let prev = head_hash_on_relay(relay, community, &community.id().0).await;
let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
let content = serde_json::to_string(&meta).unwrap();
let cite = cite_on_relay(relay, community, signer).await;
let rumor = control::build_edition_rumor(signer.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, version, prev.as_ref(), &content, at_secs, cite.as_ref());
let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
relay.publish(&wrap, &community.relays).await.unwrap();
}
#[test]
fn metadata_apply_captures_undriven_fields_for_republish() {
let owner = Keys::generate();
let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
let general = held.channels[0].id;
let mut custom = serde_json::Map::new();
custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
let mut extra = serde_json::Map::new();
extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
assert_eq!(held.meta_custom, Some(custom.clone()));
assert_eq!(held.meta_extra, extra);
assert_eq!(held.metadata().custom, Some(custom));
assert_eq!(held.metadata().extra, held.meta_extra);
let mut ch_custom = serde_json::Map::new();
ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
let ch_meta = control::ChannelMetadata {
name: "general".into(),
private: false,
voice: Some(true),
deleted: None,
custom: Some(ch_custom.clone()),
extra: Default::default(),
};
assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
let ch = held.channel(&general).unwrap();
assert_eq!(ch.voice, Some(true));
assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
assert_eq!(rename.custom, Some(ch_custom));
}
#[test]
fn community_metadata_apply_sets_and_clears_images() {
let owner = Keys::generate();
let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
let icon = control::ImageRef {
url: "https://blossom.example/i".into(),
key: "k".into(),
nonce: "n".into(),
hash: "h".into(),
extra: Default::default(),
};
let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
assert_eq!(held.icon.as_ref(), Some(&icon));
let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
assert_eq!(held.icon, None);
}
async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
let prev = head_hash_on_relay(relay, community, &role_id).await;
let content = crate::community::v2::roles::role_content_json(role).unwrap();
let cite = cite_on_relay(relay, community, signer).await;
let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, cite.as_ref());
let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
relay.publish(&wrap, &community.relays).await.unwrap();
}
async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
let prev = head_hash_on_relay(relay, community, &eid).await;
let grant = MemberGrant { member: member.to_hex(), role_ids };
let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
let cite = cite_on_relay(relay, community, signer).await;
let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
relay.publish(&wrap, &community.relays).await.unwrap();
}
async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let eid = crate::community::v2::derive::banlist_locator(community.id());
let prev = head_hash_on_relay(relay, community, &eid).await;
let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
let cite = cite_on_relay(relay, community, signer).await;
let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
relay.publish(&wrap, &community.relays).await.unwrap();
}
fn admin_role(role_id: &str, perms: u64) -> Role {
Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
}
#[tokio::test]
async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
let (bed, owner, attacker) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
let victim = Keys::generate().public_key();
grant_admin(&bed.relay, &community, &victim).await.unwrap();
let admin_rid = fetch_authority(&bed.relay, &community)
.await
.roles
.roles
.iter()
.find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
.unwrap()
.role_id
.clone();
publish_role(
&bed.relay,
&community,
&attacker.keys,
&Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
2,
)
.await;
let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
assert!(
authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
"the floor advances only to the AUTHORIZED head (owner v1)"
);
assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
}
#[tokio::test]
async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
let (bed, owner, attacker) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
let victim = Keys::generate();
grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
assert!(
authority.roles.is_admin(&victim.public_key().to_hex()),
"the forged strip is dropped; the owner's grant survives and the victim keeps admin"
);
}
#[tokio::test]
async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
let (bed, owner, attacker) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
let victim = Keys::generate().public_key();
grant_admin(&bed.relay, &community, &victim).await.unwrap();
for i in 0u8..6 {
let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
}
let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
}
fn authority_fingerprint(a: &AuthoritySet) -> String {
let mut roles = a.roles.roles.clone();
roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
let mut grants = a.roles.grants.clone();
for g in &mut grants {
g.role_ids.sort();
}
grants.sort_by(|x, y| x.member.cmp(&y.member));
let banned: Vec<&String> = a.banned.iter().collect();
serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
}
#[tokio::test]
async fn the_v2_authority_fold_is_order_independent() {
let (bed, owner, _a) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
let admin1 = Keys::generate().public_key();
let admin2 = Keys::generate().public_key();
grant_admin(&bed.relay, &community, &admin1).await.unwrap();
grant_admin(&bed.relay, &community, &admin2).await.unwrap();
let mod_rid = "5c".repeat(32);
publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
let member = Keys::generate().public_key();
publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
let banned_member = Keys::generate().public_key();
publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
create_public_channel(&bed.relay, &community, "extra").await.unwrap();
let editions = fetch_control(&bed.relay, &community).await;
let floors = load_floors(&community);
assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
let mut rev = editions.clone();
rev.reverse();
orders.push(rev);
for shift in [1usize, 3, 5, 7] {
let n = editions.len();
orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
}
let mut zip = Vec::with_capacity(editions.len());
let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
while lo <= hi {
zip.push(editions[lo as usize].clone());
if lo != hi {
zip.push(editions[hi as usize].clone());
}
lo += 1;
hi -= 1;
}
orders.push(zip);
for (i, order) in orders.iter().enumerate() {
let got = authority_fingerprint(&fold_authority(&community, order, &floors));
assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
}
assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
}
struct FetchErrors(MemoryRelay);
#[async_trait::async_trait]
impl crate::community::transport::Transport for FetchErrors {
async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
self.0.publish(e, r).await
}
async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
Err("relay down".to_string())
}
async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
self.0.publish_durable(e, r).await
}
}
#[tokio::test]
async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
let (bed, owner, victim) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
let victim_hex = victim.keys.public_key().to_hex();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
let down = FetchErrors(MemoryRelay::new());
let view = fetch_authority(&down, &community).await;
assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
}
#[tokio::test]
async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
let (bed, owner, _m) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
let session = crate::state::SessionGuard::capture();
follow_control(&bed.relay, &community, &session).await.unwrap();
assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
let relay2 = MemoryRelay::new();
publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
follow_control(&relay2, &community, &session).await.unwrap();
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
}
#[tokio::test]
async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
let admin = Keys::generate();
let rid = "a7".repeat(32);
publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
let rumor = control::build_edition_rumor(
admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
&serde_json::to_string(&meta).unwrap(), 1_000, None,
);
let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
relay.publish(&wrap, &community.relays).await.unwrap();
let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
let victim = Keys::generate().public_key().to_hex();
let ban_rumor = control::build_edition_rumor(
admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
&serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
);
let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
relay.publish(&ban_wrap, &community.relays).await.unwrap();
let session = SessionGuard::capture();
let updated = follow_control(&relay, &community, &session).await.unwrap();
assert!(
updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
"an uncited metadata edit must not be honored",
);
let authority = fetch_authority(&relay, &community).await;
assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
}
#[tokio::test]
async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
let admin = Keys::generate();
let rid = "a1".repeat(32);
publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
let session = SessionGuard::capture();
let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
let _ = follow_control(&relay, &community, &session).await.unwrap();
let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
}
#[tokio::test]
async fn a_roleless_member_cannot_edit_metadata() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
let stranger = Keys::generate();
publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
let session = SessionGuard::capture();
assert!(
follow_control(&relay, &community, &session).await.unwrap().is_none(),
"a roleless member's metadata edit never folds"
);
}
#[tokio::test]
async fn a_self_signed_grant_is_not_authority() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
let rogue = Keys::generate();
let rid = "b2".repeat(32);
publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
let session = SessionGuard::capture();
assert!(
follow_control(&relay, &community, &session).await.unwrap().is_none(),
"a self-signed grant confers no authority"
);
}
#[tokio::test]
async fn the_banlist_is_enforced_only_from_a_ban_holder() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
let target = "cc".repeat(32);
let rogue = Keys::generate();
publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
let floors = load_floors(&community);
let editions = fetch_control(&relay, &community).await;
let authority = fold_authority(&community, &editions, &floors);
assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
let editions = fetch_control(&relay, &community).await;
let authority = fold_authority(&community, &editions, &floors);
assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
}
#[tokio::test]
async fn a_banned_admin_loses_all_authority() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
let admin = Keys::generate();
let rid = "e5".repeat(32);
publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
let session = SessionGuard::capture();
assert!(
follow_control(&relay, &community, &session).await.unwrap().is_none(),
"a banned admin's edit is dropped even with an unstripped grant"
);
let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
assert!(authority.banned.contains(&admin.public_key().to_hex()));
assert!(
!authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
"a banned admin holds no bit"
);
}
#[tokio::test]
async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
let admin = Keys::generate();
let moder = Keys::generate();
let stranger = Keys::generate();
let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
publish_role(&relay, &community, &owner, &Role { role_id: admin_rid.clone(), name: "Admin".into(), position: 1, permissions: Permissions(Permissions::ADMIN_ALL), scope: RoleScope::Server, color: 0 }, 1).await;
publish_role(&relay, &community, &owner, &Role { role_id: mod_rid.clone(), name: "Mod".into(), position: 2, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
}
#[tokio::test]
async fn an_unauthorized_higher_banlist_cannot_unban() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
let target = "cc".repeat(32);
publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
let session = SessionGuard::capture();
follow_control(&relay, &community, &session).await.unwrap();
let rogue = Keys::generate();
publish_banlist(&relay, &community, &rogue, &[], 2).await; let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
}
#[tokio::test]
async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let relays = vec!["wss://r".to_string()];
let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
assert_eq!(rehydrated[0].id().0, community.id().0);
assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
}
#[tokio::test]
async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let relays = vec!["wss://r".to_string()];
let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
leave_community(&relay, &community).await.unwrap();
let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
}
#[tokio::test]
async fn accepting_the_same_bundle_twice_is_idempotent() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
create_public_channel(&bed.relay, &community, "extra").await.unwrap();
let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
bed.swap_to(&member);
let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
let channels_after_first = first.channels.len();
let root_after_first = first.community_root;
let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
assert_eq!(second.id().0, first.id().0, "same community_id");
assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
assert_eq!(second.community_root, root_after_first, "root unchanged");
let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
}
#[tokio::test]
async fn a_severed_member_can_be_unbanned_and_re_admitted() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
bed.swap_to(&member);
let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
bed.swap_to(&owner);
set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
assert_eq!(community.root_epoch, Epoch(1));
send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
bed.swap_to(&member);
let session = SessionGuard::capture();
assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
bed.swap_to(&owner);
set_banlist(&bed.relay, &community, &[]).await.unwrap();
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
let fresh_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
bed.swap_to(&member);
let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
let seen = texts_in(&bed.relay, &rejoined, &general).await;
assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
bed.swap_to(&owner);
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert!(
texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
"the re-admitted member converses again at the new epoch"
);
let members = memberlist(&bed.relay, &community).await.unwrap();
assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
}
#[tokio::test]
async fn dissolution_blocks_a_join() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
let bundle_json = serde_json::to_string(&bundle).unwrap();
dissolve_community(&bed.relay, &community).await.unwrap();
assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
bed.swap_to(&member);
let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
}
#[tokio::test]
async fn dissolution_seals_writes_but_not_reads() {
let (bed, owner, _member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
send_message(&bed.relay, &community, &general, "before the end").await.unwrap();
dissolve_community(&bed.relay, &community).await.unwrap();
let sealed = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
for err in [
send_message(&bed.relay, &sealed, &general, "after the end").await.unwrap_err(),
send_reaction(&bed.relay, &sealed, &general, &"a".repeat(64), &"b".repeat(64), crate::community::v2::kind::MESSAGE, "+", None)
.await
.unwrap_err(),
send_edit(&bed.relay, &sealed, &general, &"a".repeat(64), "revised").await.unwrap_err(),
] {
assert!(err.contains("dissolved"), "every write is refused, got: {err}");
}
assert!(
texts_in(&bed.relay, &sealed, &general).await.contains(&"before the end".to_string()),
"but the history still reads"
);
}
#[tokio::test]
async fn only_the_owner_can_dissolve() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
bed.swap_to(&member);
assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
}
#[tokio::test]
async fn a_foreign_tombstone_is_not_death() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
let rogue = Keys::generate();
let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
relay.publish(&wrap, &community.relays).await.unwrap();
assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
}
#[tokio::test]
async fn a_public_channel_reads_history_across_a_refounding() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
let general = community.channels[0].id;
send_message(&relay, &community, &general, "before the refounding").await.unwrap();
let refounded = refound_community(&relay, &community, &[]).await.unwrap();
assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
let texts = texts_in(&relay, &refounded, &general).await;
assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
}
#[tokio::test]
async fn refounding_aborts_when_control_state_is_withheld() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
let session = SessionGuard::capture();
follow_control(&relay, &community, &session).await.unwrap();
let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
moved.relays = vec!["wss://empty".into()];
crate::db::community::save_community_v2(&moved).unwrap();
let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
assert_eq!(
crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
Epoch(0),
"the epoch did NOT advance (zero published state)"
);
}
#[tokio::test]
async fn refounding_rolls_the_root_and_severs_a_removed_member() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
let bundle_json = serde_json::to_string(&bundle).unwrap();
bed.swap_to(&member);
let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
bed.swap_to(&owner);
let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
assert_eq!(
crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
Epoch(1),
"the owner committed the new epoch"
);
bed.swap_to(&member);
let session = SessionGuard::capture();
let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
assert!(follow.self_removed, "the removed member is cut by the re-founding");
}
#[tokio::test]
async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
let rid = "b0".repeat(32);
publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
let bundle_json = serde_json::to_string(&bundle).unwrap();
bed.swap_to(&member);
let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
}
#[tokio::test]
async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
let (bed, owner, me) = TestBed::new();
let admin = Keys::generate();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
let rid = "b0".repeat(32);
publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
let bundle_json = serde_json::to_string(&bundle).unwrap();
bed.swap_to(&me);
let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
let new_root = [0xC7; 32];
publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
.expect("an authorized admin's Refounding is adopted");
assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
}
#[tokio::test]
async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
let (bed, owner, me) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
let rid = "b1".repeat(32);
publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
let bundle_json = serde_json::to_string(&bundle).unwrap();
bed.swap_to(&me);
let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
let new_root = [0xD4; 32];
publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
.expect("the owner's Refounding is adopted");
assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
assert_eq!(
crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
new_root,
"and the new root behind the same URL",
);
}
#[tokio::test]
async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
let (bed, owner, me) = TestBed::new();
let admin = Keys::generate();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
let rid = "b0".repeat(32);
publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
let bundle_json = serde_json::to_string(&bundle).unwrap();
bed.swap_to(&me);
let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
}
#[tokio::test]
async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
let (bed, owner, me) = TestBed::new();
let admin_a = Keys::generate();
let admin_b = Keys::generate(); bed.swap_to(&owner);
let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
let rid = "b0".repeat(32);
publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
let bundle_json = serde_json::to_string(&bundle).unwrap();
bed.swap_to(&me);
let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
}
#[tokio::test]
async fn a_retried_refounding_reuses_the_same_root() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
}
#[tokio::test]
async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
let (bed, owner, attacker) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
let senior = "a1".repeat(32);
let mid = "a5".repeat(32);
publish_role(&bed.relay, &community, &owner.keys,
&Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
publish_role(&bed.relay, &community, &owner.keys,
&Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0 }, 1).await;
publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
publish_role(&bed.relay, &community, &attacker.keys,
&Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 2).await;
let authority = fetch_authority(&bed.relay, &community).await;
let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
assert_eq!(
folded_senior.position, 1,
"a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
);
}
#[tokio::test]
async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
let (bed, owner, admin) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
let rid = "c1".repeat(32);
publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
.filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
.max_by_key(|e| e.version).expect("the owner's metadata edition");
assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
let bundle_json = serde_json::to_string(&bundle).unwrap();
bed.swap_to(&admin);
let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
.find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
.expect("the admin's banlist edition");
let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
assert_eq!(
cite.entity_id,
crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
"the citation must name the ACTOR'S OWN grant coordinate",
);
assert!(cite.version >= 1, "pinned to a real grant version");
}
#[tokio::test]
async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
edit_community_metadata(&relay, &community, &meta).await.unwrap();
let updated = follow_control(&relay, &community, &SessionGuard::capture()).await.unwrap()
.expect("the metadata edition is folded");
assert_eq!(
updated.relays.len(),
crate::community::MAX_COMMUNITY_RELAYS,
"a folded relay list must be truncated, never adopted whole",
);
let again = follow_control(&relay, &updated, &SessionGuard::capture()).await.unwrap();
assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
}
#[tokio::test]
async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
let (bed, owner, me) = TestBed::new();
bed.swap_to(&owner);
let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
let rid = "b2".repeat(32);
publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
let bundle = bundle_of(&host, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
let bundle_json = serde_json::to_string(&bundle).unwrap();
bed.swap_to(&me);
let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
let before = bed.relay.stored_count();
let new_root = [0xE1; 32];
publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
let rotation_events = bed.relay.stored_count() - before;
let after_adopt = bed.relay.stored_count();
follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
assert_eq!(
bed.relay.stored_count(),
after_adopt,
"adopting a rotation must publish NOTHING when I minted no links here",
);
assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
}
#[tokio::test]
async fn an_expired_link_stops_keeping_the_community_public() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
let past = now_ms() - 60_000;
mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
assert!(
!community_is_public(&relay, &community).await,
"an already-expired link must never read as a live door",
);
mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
}
#[tokio::test]
async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
revoke_public_link(&relay, &community, &token_hex).await.unwrap();
assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
}
#[tokio::test]
async fn the_registry_is_cached_locally_so_public_private_is_a_sync_read() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Cached", vec!["wss://r".into()], None).await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let cached = || crate::db::community::get_community_invite_registry(&cid_hex).unwrap();
let per_creator = || crate::db::community::get_invite_link_sets(&cid_hex).unwrap();
assert!(cached().is_empty(), "a fresh community caches an empty registry");
assert!(per_creator().is_empty(), "…and no per-creator sets");
let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
assert!(!cached().is_empty(), "minting caches the registry, so the UI reads Public without folding");
let sets = per_creator();
assert_eq!(sets.len(), 1, "the minting creator gets a set");
assert_eq!(sets[0].locators.len(), 1, "carrying exactly their one live link");
let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
revoke_public_link(&relay, &community, &token_hex).await.unwrap();
assert!(cached().is_empty(), "retiring the last link empties the cache back to Private");
assert!(per_creator().is_empty(), "…and clears the per-creator sets");
}
#[tokio::test]
async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
let cid = community.id();
let control = control_group_key(&community.community_root, cid, community.root_epoch);
let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
let query = Query {
kinds: vec![stream::KIND_WRAP],
authors: vec![control.pk_hex()],
limit: Some(FOLLOW_PAGE),
..Default::default()
};
let wraps = relay.fetch(&query, &community.relays).await.unwrap();
let target = wraps
.iter()
.filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
.filter(|e| e.entity_id == eid)
.max_by_key(|e| e.version)
.expect("the owner published a registry");
let rogue = Keys::generate();
let mut planted = false;
for n in 0..4_000u64 {
let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
let rumor = control::build_edition_rumor(
rogue.public_key(),
vsk::INVITE_LINKS,
&eid,
target.version,
target.prev_hash.as_ref(),
&content,
9_000,
None,
);
let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
let (ed, _) = control::open_control_edition(&w, &control).unwrap();
if ed.inner_id < target.inner_id {
relay.publish(&w, &community.relays).await.unwrap();
planted = true;
break;
}
}
assert!(planted, "the test needs a fork that wins the tiebreak");
assert!(
community_is_public(&relay, &community).await,
"an unauthorised fork must not retire the owner's live link"
);
}
#[tokio::test]
async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
let rogue = Keys::generate();
let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
relay.publish(&wrap, &community.relays).await.unwrap();
let _ = owner;
assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
}
#[tokio::test]
async fn full_lifecycle_e2e() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
let rid = "aa".repeat(32);
publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
let bundle_json = serde_json::to_string(&bundle).unwrap();
bed.swap_to(&member);
let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
bed.swap_to(&owner);
let session = SessionGuard::capture();
let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
assert!(
texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
"pre-refounding history stays readable"
);
bed.swap_to(&member);
let session = SessionGuard::capture();
let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
assert!(follow.self_removed, "the banned member is cryptographically cut");
bed.swap_to(&owner);
dissolve_community(&bed.relay, &refounded).await.unwrap();
assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
}
#[tokio::test]
async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
let (bed, owner, member) = TestBed::new();
let attacker = Keys::generate();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
let session = SessionGuard::capture();
follow_control(&bed.relay, &community, &session).await.unwrap();
assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
publish_role(&bed.relay, &community, &attacker, &Role { role_id: rid.clone(), name: "pwn".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 }, 2).await;
let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
}
#[tokio::test]
async fn memberlist_survives_a_refounding_via_the_snapshot() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
bed.swap_to(&member);
accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
bed.swap_to(&owner);
assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
let members = memberlist(&bed.relay, &refounded).await.unwrap();
assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
}
#[tokio::test]
async fn e2e_two_accounts_channels_converse_moderate() {
use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
use nostr_sdk::prelude::ToBech32;
let (bed, a, b) = TestBed::new();
let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
println!("\n===== Concord v2 deep e2e =====");
println!("[acct] A (owner) = {a_npub}");
println!("[acct] B (member) = {b_npub}");
bed.swap_to(&a);
let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let priv_ch = community.channel(&priv_id).unwrap();
assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
println!("[msg] A posted in #general / #announcements / #mods");
let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
println!("[invite] granted B @admin · minted link {}", link.url);
grant_channel_access(&bed.relay, &community, &priv_id, &b.keys.public_key()).await.unwrap();
let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(b.keys.public_key()), Some(a.keys.public_key()), None, None)).unwrap();
bed.swap_to(&b);
let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
println!("[join] B joined; sees {} channels", b_view.channels.len());
assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
assert!(b_view.channels.iter().any(|c| c.id.0 == priv_id.0 && c.private && c.key.is_some()), "B holds the private channel key");
assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
assert!(texts_in(&bed.relay, &b_view, &priv_id).await.contains(&"A: mods-only channel".to_string()), "B reads the PRIVATE channel with the bundle key");
let session_b = SessionGuard::capture();
if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
b_view = fresh;
}
println!("[follow] B folded control (roster persisted: B is @admin)");
send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
println!("[msg] B replied in #general + #mods");
let my_pk = b.keys.public_key();
let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
if let Some(o) = outcome { persist_chat(&gh, &o).await; }
}
assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
println!("[persist] #general history persisted into the shared events store");
send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
bed.swap_to(&a);
let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
println!("[ops] reaction + edit + delete round-tripped");
bed.swap_to(&b);
let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
bed.swap_to(&a);
let session = SessionGuard::capture();
if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
community = updated;
}
assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
println!("[channel] +private #vault {} (B is unentitled — no delivery)", crate::simd::hex::bytes_to_hex_32(&vault.0));
bed.swap_to(&b);
let session_b2 = SessionGuard::capture();
if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
b_view = fresh;
}
let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
if let Some(fresh) = rf.updated {
b_view = fresh;
}
let ch = b_view.channel(&vault).expect("still recorded");
assert!(ch.key.is_none(), "an unentitled member is never delivered the key");
assert!(
texts_in(&bed.relay, &b_view, &vault).await.is_empty(),
"and reads nothing from it"
);
assert!(
send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.is_err(),
"an unentitled member cannot post into the channel either"
);
bed.swap_to(&a);
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
println!("[private] #vault stayed sealed to the unentitled B (no key, no read, no send)");
let members = memberlist(&bed.relay, &community).await.unwrap();
let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
println!("[members] {} members: A + B present", members.len());
set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
assert!(texts_in(&bed.relay, &refounded, &general).await.iter().any(|t| t == "A: welcome to the deep e2e"), "pre-ban history reads across the new epoch");
assert!(
texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
"PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
);
println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
bed.swap_to(&b);
let session_b3 = SessionGuard::capture();
assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
println!("[ban] B's rekey-follow: self_removed = true (severed)");
bed.swap_to(&a);
set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
println!("[unban] B removed from the banlist (re-invitable)");
dissolve_community(&bed.relay, &refounded).await.unwrap();
assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
}
#[tokio::test]
#[ignore]
async fn live_e2e_two_accounts() {
use crate::community::transport::LiveTransport;
use nostr_sdk::prelude::ToBech32;
let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
let relays = vec![relay.clone()];
let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
crate::db::close_database();
crate::db::clear_id_caches();
let tmp = tempfile::tempdir().unwrap();
crate::db::set_app_data_dir(tmp.path().to_path_buf());
let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
let log = |line: String| {
println!("{line}");
if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
let _ = writeln!(f, "{line}");
}
}
};
log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
log(format!("VECTOR_E2E_NSEC_A={} ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
log(format!("VECTOR_E2E_NSEC_B={} ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
for k in [&a, &b] {
let npub = k.public_key().to_bech32().unwrap();
std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
crate::db::set_current_account(npub.clone()).unwrap();
crate::db::init_database(&npub).unwrap();
}
let client = crate::nostr_client_builder().build();
client.add_managed_relay(relay.as_str()).await.ok();
client.connect().await;
crate::state::set_nostr_client(client);
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
let become_acct = |k: &Keys| {
let npub = k.public_key().to_bech32().unwrap();
crate::db::set_current_account(npub.clone()).unwrap();
crate::db::init_database(&npub).unwrap();
crate::db::clear_id_caches();
crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
crate::state::set_my_public_key(k.public_key());
};
let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
become_acct(&a);
let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
let general = community.channels[0].id;
log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
send_message(&transport, &community, &general, "A: live hello").await.expect("send");
let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
log(format!("[invite] B granted @admin · link {}", link.url));
let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(a.public_key()), None, None)).unwrap();
settle().await;
become_acct(&b);
let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
log(format!("[join] B joined; {} channels", b_view.channels.len()));
settle().await;
let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
log(format!("[read] B sees #general: {seen:?}"));
assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
let hello_id = hello.event.opened().rumor_id.to_hex();
let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
let reply_rumor = chat::build_comment_rumor(bkeys.public_key(), &general, b_view.root_epoch, "B: threaded reply to hello", &hello_id, super::super::kind::MESSAGE, &a.public_key().to_hex(), None, &[], now_ms());
let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
settle().await;
become_acct(&a);
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
assert_eq!(reply_to.as_ref().map(|r| crate::simd::hex::bytes_to_hex_32(&r.id)), Some(hello_id.clone()), "the 1111 renders inline with A's message as parent");
} else {
panic!("the 1111 parsed as a Message");
}
log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
become_acct(&b);
settle().await;
become_acct(&a);
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
settle().await;
become_acct(&b);
let session_b = SessionGuard::capture();
let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
b_view = fresh;
}
if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
b_view = fresh;
}
let vch = b_view.channel(&vault).expect("B folded the vault");
assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
let vseen = texts_in(&transport, &b_view, &vault).await;
log(format!("[read] B sees #vault: {vseen:?}"));
assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
settle().await;
become_acct(&a);
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert!(
texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
"A reads B's private reply"
);
log("[private] two-way #vault conversation over the live relay".to_string());
set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
settle().await;
dissolve_community(&transport, &refounded).await.expect("dissolve");
log("[dissolve] community sealed".to_string());
log("===== LIVE e2e PASS =====".to_string());
}
#[tokio::test]
async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
bed.swap_to(&member);
let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
bed.swap_to(&owner);
dissolve_community(&bed.relay, &community).await.unwrap();
bed.swap_to(&member);
let session = SessionGuard::capture();
let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
assert!(follow.dissolved, "the catch-up surfaces the tombstone");
assert!(!follow.self_removed && follow.updated.is_none());
let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
assert!(again.dissolved && again.updated.is_none());
}
#[tokio::test]
async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
const PRIV_CHANNELS: usize = 6;
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
let mut priv_ids = Vec::new();
for i in 0..PRIV_CHANNELS {
let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
priv_ids.push(id);
}
for id in &priv_ids {
grant_channel_access(&bed.relay, &community, id, &member.keys.public_key()).await.unwrap();
}
let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
bed.swap_to(&member);
let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
bed.swap_to(&owner);
for epoch in 1..=2u64 {
community = refound_community(&bed.relay, &community, &[]).await.unwrap();
assert_eq!(community.root_epoch, Epoch(epoch));
for id in &priv_ids {
send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
}
}
bed.swap_to(&member);
let session = SessionGuard::capture();
let mut passes = 0;
loop {
passes += 1;
assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
assert!(!rk.self_removed);
let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
if rk.updated.is_none() && ctl.is_none() {
break;
}
}
let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
for id in &priv_ids {
let mine = caught_up.channel(id).expect("channel survived");
let theirs = community.channel(id).unwrap();
assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
let texts = texts_in(&bed.relay, &caught_up, id).await;
let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
for epoch in 1..=2u64 {
assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
}
}
}
#[tokio::test]
async fn an_offline_member_catches_up_across_three_refoundings() {
use nostr_sdk::prelude::ToBech32;
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
grant_channel_access(&bed.relay, &community, &mods, &member.keys.public_key()).await.unwrap();
let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
bed.swap_to(&member);
let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
assert_eq!(member_view.root_epoch, Epoch(0));
bed.swap_to(&owner);
let stranger = Keys::generate();
for epoch in 1..=3u64 {
community = refound_community(&bed.relay, &community, &[]).await.unwrap();
assert_eq!(community.root_epoch, Epoch(epoch));
send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
}
let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
grant_channel_access(&bed.relay, &community, &vault, &member.keys.public_key()).await.unwrap();
community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
bed.swap_to(&member);
let session = SessionGuard::capture();
let mut passes = 0;
loop {
passes += 1;
assert!(passes <= 6, "catch-up must converge, not churn");
let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
assert!(!rekeyed.self_removed, "the member was never removed");
let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
if rekeyed.updated.is_none() && controlled.is_none() {
break;
}
}
let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
assert_eq!(caught_up.name, "Sleeper Reborn");
assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
let m = caught_up.channel(&mods).expect("mods survived");
let owner_mods = community.channel(&mods).unwrap();
assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
let v = caught_up.channel(&vault).expect("vault folded in");
assert!(v.private && v.key.is_none(), "vault folds in keyless: entitled, but never delivered");
let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
for epoch in 0..=3u64 {
let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
}
let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
for epoch in 0..=3u64 {
let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
}
assert!(texts_in(&bed.relay, &caught_up, &vault).await.is_empty());
send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
bed.swap_to(&owner);
assert!(
texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
"the caught-up member converses at the new epoch ({})",
member.keys.public_key().to_bech32().unwrap()
);
}
async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
let general = community.channels[0].id;
let group = channel_group_key(&community.community_root, &general, community.root_epoch);
for i in 0..n {
let at = base_secs + i as u64;
let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
relay.publish(&wrap, &community.relays).await.unwrap();
}
}
#[tokio::test]
async fn the_history_walk_pages_past_a_multi_page_burst() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
let general = community.channels[0].id;
flood_general(&relay, &community, &owner, 120, 10_000).await;
let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
assert_eq!(all.len(), 120, "the walk pages the whole burst");
let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
let unique: std::collections::HashSet<&String> = contents.iter().collect();
assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
assert_eq!(one.len(), 50, "fetch_channel is one newest page");
assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
}
#[tokio::test]
async fn a_start_until_cursor_pages_history_from_that_point_backwards() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
let general = community.channels[0].id;
flood_general(&relay, &community, &owner, 120, 10_000).await;
let older = fetch_channel_history(
&relay, &community, &general, 50, 8, None, Some(10_059),
crate::community::transport::Evidence::Quorum, |_| true,
)
.await
.unwrap();
let contents: Vec<String> = older.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
assert_eq!(contents.len(), 60, "everything at-or-before the cursor, nothing after");
assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
assert_eq!(contents.last().map(String::as_str), Some("msg 59"));
}
#[tokio::test]
async fn the_history_walk_stops_when_the_caller_is_caught_up() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
let general = community.channels[0].id;
flood_general(&relay, &community, &owner, 120, 10_000).await;
let mut pages = 0usize;
let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| {
pages += 1;
false
})
.await
.unwrap();
assert_eq!(pages, 1, "the early stop is consulted once");
assert_eq!(got.len(), 50, "only the newest page is fetched");
assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
}
#[tokio::test]
async fn a_same_second_history_wall_terminates_instead_of_looping() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
let general = community.channels[0].id;
let group = channel_group_key(&community.community_root, &general, community.root_epoch);
for i in 0..60usize {
let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
relay.publish(&wrap, &community.relays).await.unwrap();
}
let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
assert!(got.len() >= 25, "at least the relay page is read");
assert!(got.len() <= 60, "sane bound");
}
#[tokio::test]
async fn a_grant_revoke_survives_a_withholding_relay() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
let admin = Keys::generate();
let rid = "d4".repeat(32);
publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
let session = SessionGuard::capture();
follow_control(&relay, &community, &session).await.unwrap(); publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; follow_control(&relay, &community, &session).await.unwrap();
inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
let mut stale = community.clone();
stale.relays = vec!["wss://stale".into()];
let floors = load_floors(&community);
let editions = fetch_control(&relay, &stale).await;
let authority = fold_authority(&stale, &editions, &floors);
assert!(
!authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
"the persisted grant floor refuses the rolled-back (re-granted) view"
);
}
fn load_floors(community: &CommunityV2) -> Floors {
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
crate::db::community::get_all_edition_heads_full(&cid_hex)
.unwrap_or_default()
.into_iter()
.filter(|(_, f)| f.0 == community.root_epoch.0)
.map(|(e, f)| (e, (f.1, f.2, f.3)))
.collect()
}
async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
relay
.fetch(&q, &community.relays)
.await
.unwrap_or_default()
.iter()
.filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
.collect()
}
#[tokio::test]
async fn follow_control_is_a_noop_on_a_freshly_created_community() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
let session = SessionGuard::capture();
assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
}
#[tokio::test]
async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
let new_id = ChannelId([0x5a; 32]);
publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
let session = SessionGuard::capture();
let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
assert_eq!(updated.channels.len(), 2);
let added = updated.channel(&new_id).expect("the new channel folded in");
assert_eq!(added.name, "announcements");
assert!(!added.private);
assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
assert!(authors.contains(&addr), "the added channel joins the live subscription");
let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert!(reloaded.channel(&new_id).is_some());
}
#[tokio::test]
async fn follow_control_renames_the_community_and_an_existing_channel() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
let general = community.channels[0].id;
publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
let session = SessionGuard::capture();
let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
assert_eq!(updated.name, "New Name");
assert_eq!(updated.channel(&general).unwrap().name, "lobby");
assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
}
#[tokio::test]
async fn follow_control_deletes_a_channel() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
let extra = ChannelId([0x77; 32]);
let session = SessionGuard::capture();
publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
assert!(with_extra.channel(&extra).is_some());
publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
assert_eq!(updated.channels.len(), 1, "only #general remains");
}
async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
let wraps = relay.fetch(&query, &community.relays).await.unwrap();
for w in &wraps {
if let Ok((ed, _)) = control::open_control_edition(w, &group) {
if ed.version <= max_version {
relay.inject(w, &[stale_relay.to_string()]);
}
}
}
}
#[tokio::test]
async fn a_withholding_relay_cannot_roll_back_a_rename() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
let session = SessionGuard::capture();
let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
assert_eq!(updated.name, "Renamed");
inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
let mut stale_view = updated.clone();
stale_view.relays = vec!["wss://stale".into()];
assert!(
follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
"a stale-only relay must not change the held view"
);
let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
}
#[tokio::test]
async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
let extra = ChannelId([0x44; 32]);
let session = SessionGuard::capture();
publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
assert!(pruned.channel(&extra).is_none());
inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
let mut stale_view = pruned.clone();
stale_view.relays = vec!["wss://stale".into()];
assert!(
follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
"the withheld delete must not resurrect the channel"
);
let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
}
#[tokio::test]
async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
let session = SessionGuard::capture();
publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
assert_eq!(updated.name, "Edited");
let mut refounded = updated.clone();
refounded.root_epoch = crate::community::Epoch(1);
crate::db::community::save_community_v2(&refounded).unwrap();
publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
assert!(
heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
"the adopted head carries the fold's epoch + version"
);
}
#[tokio::test]
async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
let session = SessionGuard::capture();
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
assert_eq!(ours.name, "Ours");
let our_inner = {
let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
let wraps = relay.fetch(&q, &community.relays).await.unwrap();
wraps
.iter()
.find_map(|w| {
control::open_control_edition(w, &group)
.ok()
.filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
.map(|(ed, _)| ed.inner_id)
})
.unwrap()
};
let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
let content = serde_json::to_string(&meta).unwrap();
let mut ts = 2_000u64;
let fork_wrap = loop {
let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
let inner = rumor.id.unwrap().to_bytes();
if inner < our_inner {
break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
}
ts += 1;
};
relay.publish(&fork_wrap, &community.relays).await.unwrap();
let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
}
#[tokio::test]
async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
let session = SessionGuard::capture();
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
publish_community_meta(&relay, &community, &owner, "Two", 2).await;
let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
relay.publish(&w4, &community.relays).await.unwrap();
let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
relay.publish(&w3, &community.relays).await.unwrap();
let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
}
#[tokio::test]
async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
let session = SessionGuard::capture();
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
publish_community_meta(&relay, &community, &owner, "Two", 2).await;
let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
publish_community_meta(&relay, &base, &owner, "Three", 3).await; let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
let rogue = Keys::generate();
for i in 0..(FOLLOW_PAGE as u64 - 1) {
let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
relay.publish(&w, &community.relays).await.unwrap();
}
let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
relay.publish(&w4, &community.relays).await.unwrap();
let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
}
#[tokio::test]
async fn a_follow_after_delete_does_not_resurrect_the_community() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
crate::db::community::delete_community(&cid_hex).unwrap();
let session = SessionGuard::capture();
assert!(
follow_control(&relay, &community, &session).await.unwrap().is_none(),
"a follow racing a delete is a no-op"
);
assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
}
#[tokio::test]
async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
let new_root = [0xB2; 32];
publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
crate::db::community::delete_community(&cid_hex).unwrap();
let session = SessionGuard::capture();
let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
}
#[tokio::test]
async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
let r3 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&ed2.self_hash), &c3, 3_000, None);
let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
bed.relay.publish(&w3, &community.relays).await.unwrap();
let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
let bundle_json = serde_json::to_string(&bundle).unwrap();
bed.swap_to(&member);
let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
}
#[tokio::test]
async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
let session = SessionGuard::capture();
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
assert_eq!(ours.name, "Ours");
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
let content = serde_json::to_string(&meta).unwrap();
let mut ts = 5_000u64;
let fork_wrap = loop {
let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
if rumor.id.unwrap().to_bytes() > our_inner {
break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
}
ts += 1;
};
inject_stale_prefix(&relay, &community, 1, "wss://stale").await; relay.inject(&fork_wrap, &["wss://stale".to_string()]);
let mut stale_view = ours.clone();
stale_view.relays = vec!["wss://stale".into()];
assert!(
follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
"a losing fork served without our floor edition changes nothing"
);
let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
assert_eq!(held_after, held_before, "the floor row is untouched");
let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert_eq!(held.name, "Ours", "the held state is untouched");
}
#[tokio::test]
async fn follow_control_ignores_a_non_owner_edition() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
let rogue = Keys::generate();
let rogue_id = ChannelId([0x99; 32]);
publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
let session = SessionGuard::capture();
assert!(
follow_control(&relay, &community, &session).await.unwrap().is_none(),
"a non-owner control edition is not folded"
);
}
#[tokio::test]
async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
let priv_id = ChannelId([0x33; 32]);
publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
let session = SessionGuard::capture();
let updated = follow_control(&relay, &community, &session)
.await
.unwrap()
.expect("the keyless record is a change");
let ch = updated.channel(&priv_id).expect("the private channel is recorded");
assert!(ch.private && ch.key.is_none(), "recorded keyless");
assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
assert!(
fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
"a keyless fetch returns empty (and never queries the root plane)"
);
assert!(
send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
"a keyless send refuses"
);
let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
let rch = reloaded.channel(&priv_id).unwrap();
assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
let bundle = bundle_of(&reloaded, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
assert!(
!bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
"an ungrantable keyless channel stays out of invite bundles"
);
}
#[tokio::test]
async fn a_link_bundle_never_carries_a_private_channel_key() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Leak", vec!["wss://r".into()], None).await.unwrap();
create_private_channel(&relay, &community, "mods").await.unwrap();
let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let priv_hex = held
.channels
.iter()
.find(|c| c.private)
.map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
.expect("the private channel is held WITH its key");
let link = bundle_of(&held, BundleAudience::Link, None, None, None);
assert!(
!link.channels.iter().any(|c| c.id == priv_hex),
"a held private key must never ride a link bundle"
);
assert!(
link.channels.iter().any(|c| c.id != priv_hex),
"the public channels still ride it"
);
let stranger = bundle_of(&held, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
assert!(
!stranger.channels.iter().any(|c| c.id == priv_hex),
"an unentitled member gets no private key"
);
let mine = bundle_of(&held, BundleAudience::Member(me_pk().unwrap()), None, None, None);
assert!(
mine.channels.iter().any(|c| c.id == priv_hex),
"the creator is entitled via the companion access role"
);
}
#[tokio::test]
async fn a_private_channel_mints_its_access_role_and_entitlement_follows_the_grant() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Scoped", vec!["wss://r".into()], None).await.unwrap();
let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
let access = roster.channel_roles(&chan_hex);
assert_eq!(access.len(), 1, "the channel minted exactly one access role");
assert!(
access[0].permissions == crate::community::roles::Permissions::empty(),
"the access role confers READ access (key possession), never authority"
);
assert_eq!(access[0].name, "mods", "named for its channel");
let stranger = Keys::generate().public_key();
let owner_hex = community.owner().unwrap().to_hex();
assert!(!roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]));
let role_id = access[0].role_id.clone();
assert!(
roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, std::slice::from_ref(&role_id), &[]),
"the grant overlay entitles before the fold catches up"
);
let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
grant_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
assert!(
after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
"the grant landed in the local roster (the fold runs later)"
);
let vend = bundle_of(&held, BundleAudience::Member(stranger), None, None, None);
assert!(
vend.channels.iter().any(|c| c.id == chan_hex),
"a now-entitled member's bundle carries the channel key"
);
revoke_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
assert!(
!after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
"the revoke dropped the access role"
);
let rotated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert_eq!(
rotated.channel(&priv_id).unwrap().epoch,
Epoch(2),
"the revoke rotated the channel — a removal that doesn't rekey severs nobody"
);
let access = crate::VectorCore.channel_access(&cid_hex, &chan_hex).unwrap();
assert_eq!(access["private"], true);
assert_eq!(access["readable"], true, "we minted it, so we hold its key");
assert_eq!(access["roles"].as_array().unwrap().len(), 1, "one access role");
let holders = access["members"].as_array().unwrap();
let me_npub = {
use nostr_sdk::prelude::ToBech32;
me_pk().unwrap().to_bech32().unwrap()
};
assert_eq!(holders.len(), 1, "only the creator holds it — the revoked member is gone");
assert_eq!(holders[0], serde_json::json!(me_npub), "and that holder is the creator");
}
#[tokio::test]
async fn a_vended_key_parks_until_the_fold_proves_the_grant_then_adopts() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Vend", bed.relays.clone(), None).await.unwrap();
let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let real_key = held.channel(&priv_id).unwrap().key.unwrap();
let owner_hex = community.owner().unwrap().to_hex();
bed.swap_to(&member);
let me = member.keys.public_key().to_hex();
let mut member_view = held.clone();
if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
c.key = None;
c.epoch = Epoch(0);
}
let empty = crate::community::roles::CommunityRoles::default();
assert!(matches!(
judge_channel_key_vend(&member_view, &empty, &priv_id, Epoch(1), &owner_hex),
VendVerdict::Park(_)
));
let mut public_view = member_view.clone();
if let Some(c) = public_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
c.private = false;
}
assert!(matches!(
judge_channel_key_vend(&public_view, &empty, &priv_id, Epoch(1), &owner_hex),
VendVerdict::Refuse(_)
));
assert!(matches!(
judge_channel_key_vend(&member_view, &empty, &ChannelId([0x77; 32]), Epoch(1), &owner_hex),
VendVerdict::Park(_)
));
crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
crate::db::community::set_community_roles(&cid_hex, &empty, 0).unwrap();
crate::db::community::save_community_v2(&member_view).unwrap();
let session = SessionGuard::capture();
let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "unprovable vend adopts nothing");
assert_eq!(
crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
1,
"and stays parked for the next fold"
);
let access = crate::community::roles::Role {
role_id: "44".repeat(32),
name: "mods".into(),
position: u32::MAX - 1,
permissions: crate::community::roles::Permissions::empty(),
scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
color: 0,
};
let folded = crate::community::roles::CommunityRoles {
grants: vec![crate::community::roles::MemberGrant { member: me.clone(), role_ids: vec![access.role_id.clone()] }],
roles: vec![access],
};
crate::db::community::set_community_roles(&cid_hex, &folded, 1).unwrap();
let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let adopted = absorb_parked_channel_keys(&reloaded, &session);
assert_eq!(adopted.len(), 1, "the re-judge adopts once the grant folds");
let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let ch = after.channel(&priv_id).unwrap();
assert_eq!(ch.key, Some(real_key), "adopted the vended key");
assert_eq!(ch.epoch, Epoch(1));
assert!(
crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
"and the park is discharged"
);
}
#[tokio::test]
async fn a_vend_at_epoch_zero_is_adopted_onto_a_keyless_channel() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "EpochZero", bed.relays.clone(), None).await.unwrap();
let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let vended = [0x5a; 32];
let owner_hex = community.owner().unwrap().to_hex();
bed.swap_to(&member);
let mut member_view = held.clone();
if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
c.key = None;
c.epoch = Epoch(0);
}
crate::db::community::save_community_v2(&member_view).unwrap();
let access = crate::community::roles::Role {
role_id: "77".repeat(32),
name: "mods".into(),
position: u32::MAX - 1,
permissions: crate::community::roles::Permissions::empty(),
scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
color: 0,
};
let roster = crate::community::roles::CommunityRoles {
grants: vec![crate::community::roles::MemberGrant {
member: member.keys.public_key().to_hex(),
role_ids: vec![access.role_id.clone()],
}],
roles: vec![access],
};
crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &vended, &owner_hex).unwrap();
let session = SessionGuard::capture();
let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let adopted = absorb_parked_channel_keys(&reloaded, &session);
assert_eq!(adopted.len(), 1, "an epoch-0 vend onto a keyless channel is adopted");
let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let ch = after.channel(&priv_id).unwrap();
assert_eq!(ch.key, Some(vended), "the key actually landed on the row");
assert_eq!(ch.epoch, Epoch(0), "at the epoch the vendor named");
assert!(
!after.channel_read_coords(ch).is_empty(),
"and the channel is readable — the whole point"
);
assert!(
crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
"the park is discharged"
);
}
#[tokio::test]
async fn a_wildly_ahead_vend_epoch_is_refused_not_seated() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let owner_hex = community.owner().unwrap().to_hex();
bed.swap_to(&member);
let mut member_view = held.clone();
if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
c.key = None;
c.epoch = Epoch(0);
}
crate::db::community::save_community_v2(&member_view).unwrap();
let access = crate::community::roles::Role {
role_id: "99".repeat(32),
name: "mods".into(),
position: u32::MAX - 1,
permissions: crate::community::roles::Permissions::empty(),
scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
color: 0,
};
let roster = crate::community::roles::CommunityRoles {
grants: vec![crate::community::roles::MemberGrant {
member: member.keys.public_key().to_hex(),
role_ids: vec![access.role_id.clone()],
}],
roles: vec![access],
};
crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert!(matches!(
judge_channel_key_vend(&reloaded, &roster, &priv_id, Epoch(1 << 40), &owner_hex),
VendVerdict::Refuse(_)
));
crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1 << 40, &[0xEE; 32], &owner_hex).unwrap();
let session = SessionGuard::capture();
assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "a poison epoch adopts nothing");
assert!(
crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
"and the row is discharged rather than parked forever"
);
let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert_eq!(after.channel(&priv_id).unwrap().epoch, Epoch(0), "head never advanced");
assert!(matches!(
judge_channel_key_vend(&after, &roster, &priv_id, Epoch(1), &owner_hex),
VendVerdict::Accept
));
}
#[tokio::test]
async fn a_channel_rename_lands_locally_without_waiting_for_the_fold() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let key_before = held.channel(&priv_id).unwrap().key;
let mut meta = held.channel(&priv_id).unwrap().metadata();
meta.name = "staff".into();
edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let ch = after.channel(&priv_id).unwrap();
assert_eq!(ch.name, "staff", "the rename is visible immediately");
assert!(ch.private, "and privacy survives the edit");
assert_eq!(ch.key, key_before, "as does the key — a rename is not a rotation");
}
#[tokio::test]
async fn a_channel_rename_carries_its_companion_access_role() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
let before = roster.channel_roles(&chan_hex);
assert_eq!(before.len(), 1, "one companion role, minted at create");
assert_eq!(before[0].name, "mods", "named after the channel it gates");
let role_id = before[0].role_id.clone();
let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let mut meta = held.channel(&priv_id).unwrap().metadata();
meta.name = "staff".into();
edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
let after = roster.channel_roles(&chan_hex);
assert_eq!(after.len(), 1, "renamed in place, never duplicated");
assert_eq!(after[0].role_id, role_id, "a rename is a versioned edit of the same id");
assert_eq!(after[0].name, "staff", "the access role followed the channel");
assert_eq!(
after[0].permissions,
crate::community::roles::Permissions::empty(),
"and still confers read access, never authority"
);
}
#[tokio::test]
async fn a_customised_access_role_name_survives_a_channel_rename() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
let mut role = roster.channel_roles(&chan_hex)[0].clone();
role.name = "Lab Insiders".into();
set_role(&relay, &community, &role).await.unwrap();
merge_local_roster(&cid_hex, Some(&role), None);
let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let mut meta = held.channel(&priv_id).unwrap().metadata();
meta.name = "staff".into();
edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
assert_eq!(
roster.channel_roles(&chan_hex)[0].name,
"Lab Insiders",
"a deliberate name is left alone"
);
}
#[tokio::test]
async fn a_squatted_park_row_cannot_suppress_the_genuine_vend() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Squat", bed.relays.clone(), None).await.unwrap();
let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let real_key = held.channel(&priv_id).unwrap().key.unwrap();
let owner_hex = community.owner().unwrap().to_hex();
bed.swap_to(&member);
let mut member_view = held.clone();
if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
c.key = None;
c.epoch = Epoch(0);
}
crate::db::community::save_community_v2(&member_view).unwrap();
let access = crate::community::roles::Role {
role_id: "aa".repeat(32),
name: "mods".into(),
position: u32::MAX - 1,
permissions: crate::community::roles::Permissions::empty(),
scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
color: 0,
};
let roster = crate::community::roles::CommunityRoles {
grants: vec![crate::community::roles::MemberGrant {
member: member.keys.public_key().to_hex(),
role_ids: vec![access.role_id.clone()],
}],
roles: vec![access],
};
crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
let stranger = Keys::generate().public_key().to_hex();
crate::db::community::park_channel_key(&cid_hex, &chan_hex, 9, &[0xBA; 32], &stranger).unwrap();
crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
assert_eq!(
crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
2,
"the squatter never displaces the genuine vend — both are candidates"
);
let session = SessionGuard::capture();
let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let adopted = absorb_parked_channel_keys(&reloaded, &session);
assert_eq!(adopted.len(), 1, "exactly one adoption");
let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let ch = after.channel(&priv_id).unwrap();
assert_eq!(ch.key, Some(real_key), "the OWNER's key won, not the squatter's");
assert_eq!(ch.epoch, Epoch(1), "at the genuine epoch");
assert!(
crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
"and every candidate for the channel is discharged"
);
}
#[tokio::test]
async fn revoking_without_a_folded_access_role_refuses_instead_of_evicting_everyone() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "NoRole", vec!["wss://r".into()], None).await.unwrap();
let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let before = held.channel(&priv_id).unwrap().epoch;
crate::db::community::set_community_roles(&cid_hex, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
let mut blind = held.clone();
blind.relays = vec!["wss://empty".into()];
let err = revoke_channel_access(&relay, &blind, &priv_id, &Keys::generate().public_key())
.await
.unwrap_err();
assert!(err.contains("has not folded"), "refuses with a retryable reason: {err}");
let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert_eq!(after.channel(&priv_id).unwrap().epoch, before, "and rotates nothing");
}
async fn publish_base_rotation(
relay: &MemoryRelay,
community: &CommunityV2,
rotator: &Keys,
recipients: &[PublicKey],
new_root: &[u8; 32],
prev_key: &[u8; 32],
) {
let new_epoch = Epoch(community.root_epoch.0 + 1);
let prev_epoch = community.root_epoch;
let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
let blobs: Vec<_> = recipients
.iter()
.map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
.collect();
let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000, my_authority_citation(community, &rotator.public_key()).as_ref()).unwrap();
for e in &events {
relay.publish(e, &community.relays).await.unwrap();
}
}
fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
crate::db::community::save_community_v2(community).unwrap();
}
#[tokio::test]
async fn follow_rekeys_is_a_noop_without_rotations() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
let session = SessionGuard::capture();
let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
}
#[tokio::test]
async fn follow_rekeys_adopts_an_owner_base_rotation() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
let new_root = [0xB1; 32];
publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
let session = SessionGuard::capture();
let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
assert_eq!(updated.community_root, new_root, "adopted the fresh root");
let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
let general = updated.channels[0].id;
let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
}
#[tokio::test]
async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
let priv_id = ChannelId([0x33; 32]);
add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
let new_key = [0x55; 32];
let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(priv_id), Epoch(1), &new_key).unwrap();
let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None).unwrap();
for e in &events {
relay.publish(e, &community.relays).await.unwrap();
}
let session = SessionGuard::capture();
let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
let ch = updated.channel(&priv_id).unwrap();
assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
}
#[tokio::test]
async fn follow_rekeys_ignores_a_non_owner_rotation() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
let rogue = Keys::generate();
publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
let session = SessionGuard::capture();
let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
}
#[tokio::test]
async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
let session = SessionGuard::capture();
let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
}
#[tokio::test]
async fn follow_rekeys_holds_on_an_incomplete_rotation() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
let new_epoch = Epoch(1);
let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
let other = Keys::generate();
let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None).unwrap();
let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
relay.publish(&wrap, &community.relays).await.unwrap();
let session = SessionGuard::capture();
let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
}
#[tokio::test]
async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
bed.swap_to(&member);
let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
bed.swap_to(&owner);
let stranger = Keys::generate();
publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
bed.swap_to(&member);
let session = SessionGuard::capture();
let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
assert!(follow.updated.is_none(), "a removed member adopts nothing");
}
#[tokio::test]
async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
let root0 = community.community_root;
let priv_id = ChannelId([0x33; 32]);
let key1 = [0x44; 32];
add_private_channel(&mut community, priv_id, key1, Epoch(1));
let key2 = [0x55; 32];
let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
for e in rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &prev_commit, &[blob], 2_000, None).unwrap() {
relay.publish(&e, &community.relays).await.unwrap();
}
community.community_root = [0xB7; 32];
community.root_epoch = Epoch(1);
crate::db::community::save_community_v2(&community).unwrap();
let session = SessionGuard::capture();
let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
let ch = updated.channel(&priv_id).unwrap();
assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
}
#[tokio::test]
async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
let priv_id = ChannelId([0x66; 32]);
community.channels.push(ChannelV2 { id: priv_id, name: "vault".into(), private: true, key: None, epoch: Epoch(0), voice: None, meta_custom: None, meta_extra: Default::default() });
crate::db::community::save_community_v2(&community).unwrap();
let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
let stranger = Keys::generate();
let key1 = [0x71; 32];
let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
let b1 = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &stranger.public_key(), RekeyScope::Channel(priv_id), Epoch(1), &key1).unwrap();
for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None).unwrap() {
relay.publish(&e, &community.relays).await.unwrap();
}
let key2 = [0x72; 32];
let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
let b2 = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None).unwrap() {
relay.publish(&e, &community.relays).await.unwrap();
}
let session = SessionGuard::capture();
let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
let ch = updated.channel(&priv_id).unwrap();
assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
}
#[tokio::test]
async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
use crate::community::roles::{CommunityRoles, MemberGrant, Role};
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
let priv_id = ChannelId([0x88; 32]);
let key1 = [0x91; 32];
add_private_channel(&mut community, priv_id, key1, Epoch(1));
let admin = Keys::generate();
let role = Role::admin("aa".repeat(32));
let roster = CommunityRoles {
roles: vec![role.clone()],
grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
};
seed_roster_with_heads(&community, &roster, 1_000);
let key2 = [0x92; 32];
let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, my_authority_citation(&community, &admin.public_key()).as_ref()).unwrap() {
relay.publish(&e, &community.relays).await.unwrap();
}
let session = SessionGuard::capture();
let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
let rogue = Keys::generate();
let key3 = [0x93; 32];
let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None).unwrap() {
relay.publish(&e, &updated.relays).await.unwrap();
}
let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
}
#[tokio::test]
async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
use crate::community::roles::{CommunityRoles, MemberGrant, Role};
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
bed.swap_to(&member);
let mut held = community.clone();
let priv_id = ChannelId([0xAB; 32]);
let key1 = [0xA1; 32];
add_private_channel(&mut held, priv_id, key1, Epoch(1));
let peer = Keys::generate();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let role = Role::admin("bb".repeat(32));
let roster = CommunityRoles {
roles: vec![role.clone()],
grants: vec![
MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
],
};
crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
let key2 = [0xA2; 32];
let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
let pb = rekey::build_blob_local(peer.secret_key(), &peer.public_key().to_bytes(), &peer.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
for e in rekey::build_rekey_chunks_local(&peer, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[pb], 2_000, my_authority_citation(&held, &peer.public_key()).as_ref()).unwrap() {
bed.relay.publish(&e, &held.relays).await.unwrap();
}
let session = SessionGuard::capture();
let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
let key3 = [0xA3; 32];
let stranger = Keys::generate();
let ob = rekey::build_blob_local(owner.keys.secret_key(), &owner.keys.public_key().to_bytes(), &stranger.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key3).unwrap();
for e in rekey::build_rekey_chunks_local(&owner.keys, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[ob], 2_100, None).unwrap() {
bed.relay.publish(&e, &held.relays).await.unwrap();
}
let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
let updated = follow.updated.expect("the owner's removal folds");
assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
}
#[tokio::test]
async fn converting_a_public_channel_to_private_is_refused() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
let general = community.channels[0].id;
let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
}
async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
let doc = super::super::list::CommunityList {
entries: vec![],
tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
extra: Default::default(),
};
let event = super::super::list::build_list_event(me, &doc).unwrap();
relay.publish(&event, relays).await.unwrap();
}
#[tokio::test]
async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
let (_tmp, _guard, me) = init_test_db();
let relay = MemoryRelay::new();
let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
republish_community_list(&relay, Some(y.id())).await.unwrap();
let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
}
#[tokio::test]
async fn sync_tears_down_a_community_a_sibling_left() {
let (_tmp, _guard, me) = init_test_db();
let relay = MemoryRelay::new();
let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
sync_community_list(&relay, &x.relays).await.unwrap();
assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
}
#[tokio::test]
async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
let (_tmp, _guard, me) = init_test_db();
let relay = MemoryRelay::new();
let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
republish_community_list(&relay, Some(x.id())).await.unwrap();
sync_community_list(&relay, &x.relays).await.unwrap();
assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
}
#[tokio::test]
async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
let (_tmp, _guard, _me) = init_test_db();
let good = MemoryRelay::new();
let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
struct FetchErrors;
#[async_trait::async_trait]
impl Transport for FetchErrors {
async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
panic!("republish must NOT publish when the remote fetch failed");
}
async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
Ok(())
}
async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
Err("relay unreachable".to_string())
}
}
republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
}
#[tokio::test]
async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
let lurker = Keys::generate();
let rid = "b1".repeat(32);
publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
let members = memberlist(&relay, &community).await.unwrap();
assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
let banned_grantee = Keys::generate();
publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
let members = memberlist(&relay, &community).await.unwrap();
assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
let refounded = refound_community(&relay, &community, &[]).await.unwrap();
assert_eq!(refounded.root_epoch, Epoch(1));
let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
let rotations = rekey::collect_rotations(&chunks);
let lurker_x = lurker.public_key().to_bytes();
let delivered = rotations.iter().any(|r| {
rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
});
assert!(delivered, "the Refounding delivered the new root to the granted lurker");
}
#[tokio::test]
async fn the_memberlist_pages_past_a_guestbook_flood() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
let honest = Keys::generate();
let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
relay.publish(&w, &community.relays).await.unwrap();
for i in 0..600u64 {
let throwaway = Keys::generate();
let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
relay.publish(&w, &community.relays).await.unwrap();
}
let members = memberlist(&relay, &community).await.unwrap();
assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
}
#[tokio::test]
async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
let new_root = [0xD9; 32];
let new_epoch = Epoch(1);
let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
let rogue = Keys::generate();
let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
for i in 0..260u64 {
let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
let rumor = rekey::build_rekey_rumor(rogue.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 1, 3_000 + i, None).unwrap();
let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
relay.publish(&wrap, &community.relays).await.unwrap();
}
let session = SessionGuard::capture();
let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
assert_eq!(updated.root_epoch, Epoch(1));
assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
}
#[tokio::test]
async fn a_swap_during_create_private_channel_aborts_without_a_write() {
let (bed, owner, _member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
let swap_relay = SwapMidPublish { inner: MemoryRelay::new() };
let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert_eq!(after.channels.len(), before, "no channel row was written");
assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
}
#[tokio::test]
async fn an_uncited_admin_rotation_is_not_adopted() {
use crate::community::roles::{CommunityRoles, MemberGrant, Role};
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
let priv_id = ChannelId([0x8A; 32]);
let key1 = [0x93; 32];
add_private_channel(&mut community, priv_id, key1, Epoch(1));
let admin = Keys::generate();
let role = Role::admin("cf".repeat(32));
let roster = CommunityRoles {
roles: vec![role.clone()],
grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
};
seed_roster_with_heads(&community, &roster, 1_000);
let key2 = [0x94; 32];
let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None).unwrap() {
relay.publish(&e, &community.relays).await.unwrap();
}
let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
assert!(out.updated.is_none(), "an uncited rotation is not adopted");
let cited = my_authority_citation(&community, &admin.public_key());
assert!(cited.is_some(), "the seeded head yields a citation");
let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob2], 2_100, cited.as_ref()).unwrap() {
relay.publish(&e, &community.relays).await.unwrap();
}
let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
assert!(out.updated.is_some(), "the cited rotation IS adopted");
}
#[tokio::test]
async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
use crate::community::roles::{CommunityRoles, MemberGrant, Role};
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
let priv_id = ChannelId([0xC0; 32]);
let key1 = [0xC1; 32];
add_private_channel(&mut community, priv_id, key1, Epoch(1));
let (a, b) = (Keys::generate(), Keys::generate());
let role = Role::admin("ce".repeat(32));
let roster = CommunityRoles {
roles: vec![role.clone()],
grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
};
seed_roster_with_heads(&community, &roster, 1_000);
let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
let key_a = [0x0A; 32];
let key_b = [0xFB; 32]; for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
for e in rekey::build_rekey_chunks_local(signer, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, my_authority_citation(&community, &signer.public_key()).as_ref()).unwrap() {
relay.publish(&e, &community.relays).await.unwrap();
}
}
let session = SessionGuard::capture();
let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
let mut peer = community.clone();
if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
c.key = Some(key1);
c.epoch = Epoch(1);
}
let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
}
#[tokio::test]
async fn create_private_channel_refuses_a_member_without_manage_channels() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
bed.swap_to(&member);
let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
}
#[tokio::test]
async fn accept_rejects_a_bundle_with_a_forged_community_root() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
forged.community_root = fake.clone();
for ch in &mut forged.channels {
ch.key = fake.clone();
}
let attacker = Keys::generate();
let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
bed.relay.publish(&wrap, &bed.relays).await.unwrap();
bed.swap_to(&member);
let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
assert!(
crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
"a rejected join persists nothing"
);
}
#[tokio::test]
async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
let mut rotated = community.clone();
rotated.community_root = [0x5A; 32];
rotated.root_epoch = Epoch(1);
let admin = Keys::generate();
publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
bed.swap_to(&member);
let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
let session = SessionGuard::capture();
let joined = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
}
#[tokio::test]
async fn only_an_actual_join_publishes_a_guestbook_join() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
}
let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await;
bed.swap_to(&member);
let bundle = bundle_of(&community, BundleAudience::Link, None, None, None);
let session = SessionGuard::capture();
accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
crate::db::community::delete_community(&cid_hex).unwrap();
accept_bundle(&bed.relay, &session, &bundle, None, false).await.unwrap();
assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
}
#[tokio::test]
async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
let mut rotated = community.clone();
rotated.community_root = [0x5B; 32];
rotated.root_epoch = Epoch(1);
let attacker = Keys::generate();
publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
bed.swap_to(&member);
let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
let session = SessionGuard::capture();
let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
}
#[tokio::test]
async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
let mut fake = community.clone();
fake.community_root = [0x5C; 32]; let admin = Keys::generate();
publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
bed.swap_to(&member);
let bundle = bundle_of(&fake, BundleAudience::Link, None, None, None);
let session = SessionGuard::capture();
let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
}
#[tokio::test]
async fn follow_control_heals_a_bundle_misclassified_public_channel() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
let general = community.channels[0].id;
let mut poisoned = community.clone();
poisoned.channels[0].private = true;
poisoned.channels[0].key = Some([0x66; 32]);
crate::db::community::save_community_v2(&poisoned).unwrap();
let session = SessionGuard::capture();
let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
let ch = healed.channel(&general).unwrap();
assert!(!ch.private, "the owner's public declaration overrides the bundle");
assert_eq!(ch.key, None, "a healed public channel derives from the root");
}
#[tokio::test]
async fn a_deleted_channel_does_not_resurrect_on_reload() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
let extra = ChannelId([0x77; 32]);
let session = SessionGuard::capture();
publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
assert!(with_extra.channel(&extra).is_some());
publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
assert!(after.channel(&extra).is_none());
let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
assert_eq!(reloaded.channels.len(), 1);
}
#[tokio::test]
async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
let a_channel = a.channels[0].id;
let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
let b_channel = b.channels[0].id;
b.channels.push(ChannelV2 { id: a_channel, name: "phantom".into(), private: false, key: None, epoch: b.root_epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
}
struct CappedRelay {
events: Vec<Event>,
cap: usize,
}
#[async_trait::async_trait]
impl Transport for CappedRelay {
async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
Ok(())
}
async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
Ok(())
}
async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
let mut m: Vec<Event> = self
.events
.iter()
.filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
.filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
.cloned()
.collect();
m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
Ok(m)
}
}
#[tokio::test]
async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
let (_tmp, _guard, _owner) = init_test_db();
let memory = MemoryRelay::new();
let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
let rogue = Keys::generate();
let mut events: Vec<Event> = Vec::new();
for i in 0..FOLLOW_PAGE {
let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
let rumor = control::build_edition_rumor(
rogue.public_key(),
vsk::CHANNEL_METADATA,
&[0xAB; 32],
1,
None,
&content,
9_000,
None,
);
let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
events.push(w);
}
let relay = CappedRelay { events, cap: FOLLOW_PAGE };
let err = refound_community(&relay, &community, &[])
.await
.expect_err("a plane that can't be read whole must never be compacted");
assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
}
#[tokio::test]
async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
let (_tmp, _guard, owner) = init_test_db();
let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
let g = control::genesis(&owner, meta, 1_000).unwrap();
let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
let rogue = Keys::generate();
let mut events: Vec<Event> = g.wraps.to_vec();
for i in 0..250u64 {
let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
events.push(wrap);
}
let relay = CappedRelay { events, cap: 100 };
let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
}
#[tokio::test]
async fn accept_parked_invite_joins_from_the_stored_bundle() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
let bundle_json = serde_json::to_string(&bundle).unwrap();
let inviter_hex = owner.keys.public_key().to_hex();
bed.swap_to(&member);
let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
assert!(joined.identity.verify());
assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
assert!(
crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
"the joiner's control floor is seeded from the join-time fold"
);
bed.swap_to(&owner);
let members = memberlist(&bed.relay, &community).await.unwrap();
assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
}
#[tokio::test]
async fn accept_parked_invite_rejects_a_forged_root() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
forged.community_root = fake.clone();
for ch in &mut forged.channels {
ch.key = fake.clone();
}
let bundle_json = serde_json::to_string(&forged).unwrap();
let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
}
#[test]
fn v2_and_v1_bundles_are_distinguishable_by_parse() {
let owner = Keys::generate();
let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
let hex = crate::simd::hex::bytes_to_hex_32;
let v2 = invite::CommunityInvite {
community_id: hex(&identity.community_id.0),
owner: hex(&identity.owner_xonly),
owner_salt: hex(&identity.owner_salt),
community_root: hex(&[0x11; 32]),
root_epoch: 0,
channels: vec![],
relays: vec!["wss://r".into()],
name: "V2".into(),
icon: None,
expires_at: None,
creator_npub: None,
label: None,
extra: Default::default(),
};
let v2_json = serde_json::to_string(&v2).unwrap();
assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
}
#[tokio::test]
async fn verify_rejects_a_cross_community_owner_edition_replay() {
let (_tmp, _guard, owner) = init_test_db();
let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
let t_identity = control::CommunityIdentity::mint(&owner.public_key());
let fake_root = [0xEE; 32];
let t = CommunityV2 {
identity: t_identity,
community_root: fake_root,
root_epoch: Epoch(0),
name: "T".into(),
description: None,
icon: None,
banner: None,
meta_custom: None,
meta_extra: Default::default(),
relays: vec!["wss://r".into()],
channels: vec![],
dissolved: false,
created_at_ms: 0,
};
let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
let relay = MemoryRelay::new();
relay.publish(&replayed, &t.relays).await.unwrap();
let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
}
#[tokio::test]
#[ignore = "hits a real relay over the network"]
async fn live_smoke_create_send_fetch_on_a_real_relay() {
use crate::community::transport::LiveTransport;
use nostr_sdk::prelude::ToBech32;
let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
let relays = vec![relay.clone()];
let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
crate::db::close_database();
crate::db::clear_id_caches();
let tmp = tempfile::tempdir().unwrap();
let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
Err(_) => Keys::generate(),
};
let npub = keys.public_key().to_bech32().unwrap();
if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
}
std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
crate::db::set_app_data_dir(tmp.path().to_path_buf());
crate::db::set_current_account(npub.clone()).unwrap();
crate::db::init_database(&npub).unwrap();
crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
crate::state::set_my_public_key(keys.public_key());
println!("[smoke] throwaway identity {npub}");
let client = crate::nostr_client_builder().build();
client.add_managed_relay(relay.as_str()).await.ok();
client.connect().await;
crate::state::set_nostr_client(client);
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
let general = community.channels[0].id;
println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
let text = "hello from a Vector Concord v2 live smoke test";
let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
println!("[smoke] sent message {sent_id}");
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
let texts: Vec<String> = page
.iter()
.filter_map(|f| match &f.event {
ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
_ => None,
})
.collect();
println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
println!("[smoke] invite link: {}", link.url);
println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
}
#[tokio::test]
async fn chat_ops_react_edit_delete_round_trip() {
let (bed, owner, _member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
let me_hex = owner.keys.public_key().to_hex();
let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
.await
.unwrap();
send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
let mut saw = (false, false, false);
for f in &page {
match &f.event {
ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
assert_eq!(emoji, ":fire:");
assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
saw.0 = true;
}
ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
assert_eq!(new_content, "edited");
saw.1 = true;
}
ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
_ => {}
}
}
assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
}
#[tokio::test]
async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
let (bed, owner, _member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
let group = channel_group_key(&community.community_root, &general, community.root_epoch);
let mut sub = bed.relay.subscribe(Query {
kinds: vec![stream::KIND_WRAP_EPHEMERAL],
authors: vec![group.pk_hex()],
..Default::default()
});
send_typing(&bed.relay, &community, &general).await.unwrap();
let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
Ok(ChatEvent::Typing { opened }) => opened,
other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
};
let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
assert!(
crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
"ephemeral sends must not retain scrub keys"
);
}
#[tokio::test]
async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
let (bed, owner, _member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
let group = channel_group_key(&community.community_root, &general, community.root_epoch);
let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
let (keys, outer_hex, relays) =
crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
assert_eq!(relays, community.relays);
let wrap_query = Query {
kinds: vec![stream::KIND_WRAP],
authors: vec![group.pk_hex()],
..Default::default()
};
let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
let me_hex = owner.keys.public_key().to_hex();
let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
.await
.unwrap();
assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
}
#[tokio::test]
async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
let (bed, owner, _member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
let group = channel_group_key(&community.community_root, &general, community.root_epoch);
let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
crate::db::community::delete_message_key(&id).unwrap();
assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
let mkeys = Keys::generate();
let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
let foreign_id = rumor.id.unwrap().to_hex();
let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
bed.relay.publish(&fw, &community.relays).await.unwrap();
fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
let (keys, _outer, relays) =
crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
assert_eq!(relays, community.relays);
assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
assert!(
!left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
"healed message scrubbed from the relay"
);
}
#[tokio::test]
async fn send_chat_message_threads_the_reply_and_extra_tags() {
let (bed, owner, _member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
let general = community.channels[0].id;
let me_hex = owner.keys.public_key().to_hex();
let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
let imeta = nostr_sdk::prelude::Tag::custom(
"imeta",
["url https://e/blob".to_string(), "m image/png".to_string()],
);
let child_id = send_chat_message(
&bed.relay, &community, &general, "child",
Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
)
.await
.unwrap();
let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
let child = page
.iter()
.find_map(|f| match &f.event {
ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
_ => None,
})
.expect("the reply message round-trips");
let reply = child.1.as_ref().expect("the reply reference is carried");
assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
assert_eq!(reply.author, Some(owner.keys.public_key()));
assert!(
child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
"the imeta attachment tag rides the rumor verbatim"
);
}
#[tokio::test]
async fn a_kick_needs_kick_authority_and_removes_the_target() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
bed.relay.publish(&wrap, &bed.relays).await.unwrap();
let before = memberlist(&bed.relay, &community).await.unwrap();
assert!(before.contains(&member.keys.public_key()), "the join lands first");
bed.swap_to(&member);
let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
bed.swap_to(&owner);
kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
let after = memberlist(&bed.relay, &community).await.unwrap();
assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
assert!(after.contains(&owner.keys.public_key()), "the owner remains");
}
#[tokio::test]
async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let (o, m) = (owner.keys.public_key(), member.keys.public_key());
let join = |at: u64, id: u8| guestbook::GuestbookEvent {
rumor_id: [id; 32],
entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
};
let kick = |at: u64, id: u8| guestbook::GuestbookEvent {
rumor_id: [id; 32],
entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
};
crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
assert!(
!stored_memberlist(&community).unwrap().contains(&m),
"the memberlist excludes an un-caught-up member — why it can't gate a kick"
);
}
fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
for g in &roster.grants {
let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
let eid = super::super::derive::grant_locator(community.id(), &m);
let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
}
}
async fn publish_grant_citing(
relay: &MemoryRelay,
community: &CommunityV2,
signer: &Keys,
member: &PublicKey,
role_ids: Vec<String>,
version: u64,
citation: Option<&crate::community::edition::AuthorityCitation>,
) {
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
let prev = head_hash_on_relay(relay, community, &eid).await;
let grant = MemberGrant { member: member.to_hex(), role_ids };
let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
relay.publish(&wrap, &community.relays).await.unwrap();
}
#[tokio::test]
async fn an_uncited_admin_edition_is_not_folded_but_a_cited_one_is() {
let (bed, owner, admin) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
let admin_pk = admin.keys.public_key();
let rid = "c3".repeat(32);
publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
let low_rid = "c4".repeat(32);
let mut low = admin_role(&low_rid, Permissions::admin().0);
low.position = 5;
publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
let bystander = Keys::generate().public_key();
publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
let view = fetch_authority(&bed.relay, &community).await;
assert!(
!view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
"an uncited non-owner edition is not folded"
);
assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant folds");
let _ = follow_control(&bed.relay, &community, &SessionGuard::capture()).await;
let entity_id = crate::community::v2::derive::grant_locator(community.id(), &admin_pk.to_bytes());
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid], 2, Some(&cite)).await;
let view = fetch_authority(&bed.relay, &community).await;
assert!(
view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
"the same edition WITH its synced citation folds"
);
}
#[tokio::test]
async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
let member_pk = member.keys.public_key();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
let join = guestbook::GuestbookEvent {
rumor_id: [9u8; 32],
entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
};
assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
assert!(
!stored_memberlist(&community).unwrap().contains(&member_pk),
"while banned, the fold keeps them out"
);
crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
assert!(
stored_memberlist(&community).unwrap().contains(&member_pk),
"after the unban the raced Join makes them a member again"
);
}
#[tokio::test]
async fn a_stale_root_admin_write_is_refused_not_misdirected() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
let member_pk = member.keys.public_key();
set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
assert!(err.contains("re-founded"), "unban: {err}");
let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
assert!(err.contains("re-founded"), "invite: {err}");
let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
assert!(err.contains("re-founded"), "kick: {err}");
let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
let view = fetch_authority(&bed.relay, &fresh).await;
assert!(view.banned.is_empty(), "the retried unban actually unbans");
}
#[tokio::test]
async fn an_uncited_kick_from_an_admin_is_not_honored() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
let admin = Keys::generate();
let member_pk = member.keys.public_key();
grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let view = fetch_authority(&bed.relay, &community).await;
crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
let joined = guestbook::GuestbookEvent {
rumor_id: [1u8; 32],
entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
};
let kick = |citation, id: u8, at| guestbook::GuestbookEvent {
rumor_id: [id; 32],
entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
};
let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
let empty_bans = std::collections::BTreeSet::new();
let empty_marks = std::collections::BTreeMap::new();
let fold = |evs: &[guestbook::GuestbookEvent]| {
fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
};
assert!(
fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
"an uncited kick from an admin is not honored"
);
assert!(
!fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
"the same kick WITH its synced citation removes them"
);
}
#[tokio::test]
async fn kicking_an_admin_strips_their_roles_first() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Compose", bed.relays.clone(), None).await.unwrap();
let member_pk = member.keys.public_key();
let member_hex = member_pk.to_hex();
let owner_hex = owner.keys.public_key().to_hex();
grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
kick_member(&bed.relay, &community, &member_pk).await.unwrap();
let view = fetch_authority(&bed.relay, &community).await;
assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
assert!(
!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
"a kicked admin holds no bit"
);
assert!(
!memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
"and the directive still removed them"
);
}
#[tokio::test]
async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
let member_pk = member.keys.public_key();
let member_hex = member_pk.to_hex();
let owner_hex = owner.keys.public_key().to_hex();
grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
let view = fetch_authority(&bed.relay, &community).await;
assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
let second = Keys::generate().public_key();
grant_admin(&bed.relay, &community, &second).await.unwrap();
grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
let view = fetch_authority(&bed.relay, &community).await;
assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
let view = fetch_authority(&bed.relay, &community).await;
assert!(!view.roles.is_admin(&member_hex), "revoked");
assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
}
#[tokio::test]
async fn follow_control_persists_the_roster_for_sync_local_reads() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let member_hex = member.keys.public_key().to_hex();
grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
let session = crate::state::SessionGuard::capture();
follow_control(&bed.relay, &community, &session).await.unwrap();
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
let withholding = MemoryRelay::new();
let _ = follow_control(&withholding, &community, &session).await;
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
follow_control(&bed.relay, &community, &session).await.unwrap();
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
}
#[tokio::test]
async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
bed.swap_to(&member);
let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
assert!(err.contains("owner"), "refused before any publish: {err}");
bed.swap_to(&owner);
let view = fetch_authority(&bed.relay, &community).await;
assert!(view.roles.roles.is_empty(), "no role edition landed");
grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
let view = fetch_authority(&bed.relay, &community).await;
assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
}
#[tokio::test]
async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
let member_pk = member.keys.public_key();
let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
let withholding = MemoryRelay::new();
let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
let view = fetch_authority(&bed.relay, &community).await;
let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
assert!(grant.role_ids.contains(&mod_rid));
}
#[tokio::test]
async fn fetch_authority_reflects_a_granted_admin() {
let (bed, owner, member) = TestBed::new();
bed.swap_to(&owner);
let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
let view = fetch_authority(&bed.relay, &community).await;
let member_hex = member.keys.public_key().to_hex();
assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
assert!(
view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
"an ADMIN_ALL grant carries KICK"
);
assert!(view.banned.is_empty());
}
#[tokio::test]
async fn a_pin_edition_folds_persists_and_reads_back() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Pinsville", vec!["wss://r".into()], None).await.unwrap();
let general = community.channels[0].id;
let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
send_message(&relay, &community, &general, "pin-worthy").await.unwrap();
let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
let opened = page
.iter()
.find_map(|f| match &f.event {
ChatEvent::Message { opened, .. } => Some(opened.clone()),
_ => None,
})
.expect("the sent message reads back");
let ch = community.channels[0].clone();
let conv = channel_conv_key_at(&community, &ch, 0).expect("owner holds the public plane key");
let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
let session = crate::state::SessionGuard::capture();
let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
publish_control_edition(&relay, &community, &session, vsk::PINS, &eid, &content).await.unwrap();
follow_control(&relay, &community, &session).await.unwrap();
let read = read_channel_pins(&community, &general).unwrap();
assert!(!read.sealed);
assert!(read.version >= 1, "the folded head persisted");
assert_eq!(read.pins.len(), 1);
assert_eq!(read.pins[0].content, "pin-worthy");
assert_eq!(read.pins[0].rumor_id, opened.rumor_id.to_hex());
}
#[tokio::test]
async fn an_unauthorized_pin_edition_never_folds() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
let general = community.channels[0].id;
let rogue = Keys::generate();
let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
let rumor = control::build_edition_rumor(rogue.public_key(), vsk::PINS, &eid, 1, None, r#"{"entries":[]}"#, 2_000, None);
let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(2_000)).unwrap();
relay.publish(&wrap, &community.relays).await.unwrap();
let session = crate::state::SessionGuard::capture();
follow_control(&relay, &community, &session).await.unwrap();
let read = read_channel_pins(&community, &general).unwrap();
assert_eq!(read.version, 0, "an unauthorized edition never persists a head");
assert!(read.pins.is_empty());
}
#[tokio::test]
async fn owner_silently_widens_a_legacy_admin_role() {
let (_tmp, _guard, owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Legacy", vec!["wss://r".into()], None).await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let rid = crate::simd::hex::bytes_to_hex_32(&[0x5d; 32]);
let legacy = Role {
role_id: rid.clone(),
name: "Admin".into(),
position: 1,
permissions: Permissions(Permissions::ADMIN_FOUNDING_MASK),
scope: RoleScope::Server,
color: 0,
};
publish_role(&relay, &community, &owner, &legacy, 1).await;
let session = crate::state::SessionGuard::capture();
follow_control(&relay, &community, &session).await.unwrap();
assert!(upgrade_admin_role_pin_bit(&relay, &community).await.unwrap(), "the widening publishes");
follow_control(&relay, &community, &session).await.unwrap();
let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
let widened = roles.roles.iter().find(|r| r.role_id == rid).expect("same entity");
assert!(widened.permissions.contains(Permissions::PIN_MESSAGES), "bit 11 landed");
assert!(widened.permissions.contains(Permissions::ADMIN_FOUNDING_MASK), "nothing stripped");
assert!(!upgrade_admin_role_pin_bit(&relay, &community).await.unwrap());
}
#[tokio::test]
async fn the_deletion_duty_omits_a_pinned_message() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Duties", vec!["wss://r".into()], None).await.unwrap();
let general = community.channels[0].id;
let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
let rumor_id = send_message(&relay, &community, &general, "soon deleted").await.unwrap();
let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
let opened = page
.iter()
.find_map(|f| match &f.event {
ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
_ => None,
})
.unwrap();
let ch = community.channels[0].clone();
let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
let session = crate::state::SessionGuard::capture();
publish_pin_list(&relay, &community, &session, &ch, &[entry]).await.unwrap();
assert_eq!(read_channel_pins(&community, &general).unwrap().pins.len(), 1);
run_pin_duty(&relay, &ch_hex, &rumor_id, None, crate::state::SessionGuard::capture()).await.unwrap();
let after = read_channel_pins(&community, &general).unwrap();
assert!(after.pins.is_empty(), "the omitting edition landed");
assert!(after.version >= 2, "a NEW edition, not a local erase");
}
#[tokio::test]
async fn the_edit_duty_refreshes_a_pinned_proof() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Edits", vec!["wss://r".into()], None).await.unwrap();
let general = community.channels[0].id;
let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
let rumor_id = send_message(&relay, &community, &general, "first words").await.unwrap();
let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
let opened = page
.iter()
.find_map(|f| match &f.event {
ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
_ => None,
})
.unwrap();
let ch = community.channels[0].clone();
let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
let session = crate::state::SessionGuard::capture();
publish_pin_list(&relay, &community, &session, &ch, &[entry]).await.unwrap();
send_edit(&relay, &community, &general, &rumor_id, "second thoughts").await.unwrap();
let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
let edit_opened = page
.iter()
.find_map(|f| match &f.event {
ChatEvent::Edit { opened, .. } => Some(opened.clone()),
_ => None,
})
.expect("the edit reads back");
run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened.clone()), crate::state::SessionGuard::capture()).await.unwrap();
let after = read_channel_pins(&community, &general).unwrap();
assert_eq!(after.pins.len(), 1);
assert_eq!(
after.pins[0].content, "second thoughts",
"the refreshed bundle proves the revision"
);
let v = after.version;
run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened), crate::state::SessionGuard::capture()).await.unwrap();
assert_eq!(read_channel_pins(&community, &general).unwrap().version, v, "idempotent");
}
#[tokio::test]
async fn a_rotation_reseals_the_pin_list_under_the_new_epoch() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Reseal", vec!["wss://r".into()], None).await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let chan = create_private_channel(&relay, &community, "vault").await.unwrap();
let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let ch_hex = crate::simd::hex::bytes_to_hex_32(&chan.0);
let rumor_id = send_message(&relay, &community, &chan, "sealed wisdom").await.unwrap();
let page = fetch_channel(&relay, &community, &chan, 10).await.unwrap();
let opened = page
.iter()
.find_map(|f| match &f.event {
ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
_ => None,
})
.unwrap();
let ch = community.channel(&chan).unwrap().clone();
let old_epoch = ch.epoch.0;
let conv = channel_conv_key_at(&community, &ch, old_epoch).unwrap();
let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
let session = crate::state::SessionGuard::capture();
publish_pin_list(&relay, &community, &session, &ch, &[entry]).await.unwrap();
let (_, v_before) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
rekey_channel_excluding(&relay, &community, &chan, &roster, &[], &Keys::generate().public_key())
.await
.unwrap();
let (content, version) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
parsed["epoch"].as_str().unwrap(),
(old_epoch + 1).to_string(),
"the reseal names the rotated epoch"
);
assert!(version > v_before, "a real edition, not a local rewrite");
let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
let read = read_channel_pins(&community, &chan).unwrap();
assert!(!read.sealed);
assert_eq!(read.pins.len(), 1);
assert_eq!(read.pins[0].content, "sealed wisdom");
}
#[tokio::test]
async fn a_published_banlist_echoes_locally_before_any_fold() {
let (_tmp, _guard, _owner) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Modtown", vec!["wss://r".into()], None).await.unwrap();
let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
let spammer_a = "aa".repeat(32);
let spammer_b = "bb".repeat(32);
set_banlist(&relay, &community, &[spammer_a.clone()]).await.unwrap();
assert_eq!(
crate::db::community::get_community_banlist(&cid_hex).unwrap(),
vec![spammer_a.clone()],
"the publish echoes without waiting for a fold"
);
let mut list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
list.push(spammer_b.clone());
set_banlist(&relay, &community, &list).await.unwrap();
let held = crate::db::community::get_community_banlist(&cid_hex).unwrap();
assert!(
held.contains(&spammer_a) && held.contains(&spammer_b),
"sequential bans UNION; the second must not erase the first: {held:?}"
);
let session = crate::state::SessionGuard::capture();
follow_control(&relay, &community, &session).await.unwrap();
let folded = crate::db::community::get_community_banlist(&cid_hex).unwrap();
assert!(folded.contains(&spammer_a) && folded.contains(&spammer_b));
}
}