use nostr_sdk::prelude::{Event, EventId, JsonUtil, Keys, Tag, ToBech32};
use super::invite::CommunityInvite;
use super::public_invite::{
self, build_public_invite_event, locator_hex, parse_public_invite_event, PublicInviteBundle,
};
use super::send::{delete_own_message, publish_signed_message};
use super::transport::{Query, Transport};
use super::{Channel, Community};
use crate::state::SessionGuard;
use crate::stored_event::event_kind;
async fn active_signer() -> Result<std::sync::Arc<dyn nostr_sdk::prelude::NostrSigner>, String> {
if let Some(client) = crate::state::nostr_client() {
if let Ok(s) = client.signer().await {
return Ok(s);
}
}
let keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no signer available (no client and no local key)")?;
Ok(std::sync::Arc::new(keys))
}
pub const MAX_COMMUNITIES: usize = 50;
fn enforce_community_cap() -> Result<(), String> {
let held = super::list::load_local_list().entries.len();
if held >= MAX_COMMUNITIES {
return Err(format!(
"You've reached the limit of {} communities. Leave one to join another.",
MAX_COMMUNITIES
));
}
Ok(())
}
pub async fn create_community<T: Transport + ?Sized>(
transport: &T,
name: &str,
default_channel_name: &str,
relays: Vec<String>,
) -> Result<Community, String> {
let session = SessionGuard::capture();
enforce_community_cap()?;
let mut community = Community::create(name, default_channel_name, relays);
let owner_pk = crate::state::my_public_key().ok_or("cannot create a community without an identity")?;
let unsigned = super::owner::build_owner_attestation_unsigned(owner_pk, &community.id.to_hex());
let attestation = if let Some(keys) = crate::state::MY_SECRET_KEY.to_keys().filter(|k| k.public_key() == owner_pk) {
unsigned.sign_with_keys(&keys).map_err(|e| format!("sign owner attestation: {e}"))?
} else if let Some(client) = crate::state::nostr_client() {
let signer = client.signer().await.map_err(|e| format!("no signer for owner attestation: {e}"))?;
unsigned.sign(&signer).await.map_err(|e| format!("sign owner attestation: {e}"))?
} else {
return Err("cannot create a community without an identity signer (the owner attestation is mandatory)".to_string());
};
community.owner_attestation = Some(attestation.as_json());
if !session.is_valid() {
return Err("account changed during community creation".to_string());
}
crate::db::community::save_community(&community)?;
let signer = active_signer().await?;
let cid = community.id.to_hex();
let created = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let admin = super::roles::Role::admin(crate::simd::hex::bytes_to_hex_32(&super::random_32()));
let root_meta = super::metadata::CommunityMetadata::of(&community);
let root_inner = super::roster::build_community_root_edition_unsigned(owner_pk, &community.id, &root_meta, 1, None, created, None)?
.sign(&signer).await.map_err(|e| format!("sign genesis group-root: {e}"))?;
let role_inner = super::roster::build_role_edition_unsigned(owner_pk, &admin, 1, None, created, None)?
.sign(&signer).await.map_err(|e| format!("sign genesis admin-role: {e}"))?;
let mut heads: Vec<(String, [u8; 32], Option<[u8; 32]>)> = vec![
(cid.clone(), super::version::edition_hash(&community.id.0, 1, None, root_inner.content.as_bytes()), Some(root_inner.id.to_bytes())),
(admin.role_id.clone(), super::version::edition_hash(&crate::simd::hex::hex_to_bytes_32(&admin.role_id), 1, None, role_inner.content.as_bytes()), None),
];
let mut to_publish: Vec<Event> = vec![
super::roster::seal_control_edition(&Keys::generate(), &root_inner, &community.server_root_key, &community.id, community.server_root_epoch)?,
super::roster::seal_control_edition(&Keys::generate(), &role_inner, &community.server_root_key, &community.id, community.server_root_epoch)?,
];
for channel in &community.channels {
let meta = super::metadata::ChannelMetadata { name: channel.name.clone() };
let inner = super::roster::build_channel_metadata_edition_unsigned(owner_pk, &channel.id, &meta, 1, None, created, None)?
.sign(&signer).await.map_err(|e| format!("sign genesis channel-metadata: {e}"))?;
heads.push((channel.id.to_hex(), super::version::edition_hash(&channel.id.0, 1, None, inner.content.as_bytes()), Some(inner.id.to_bytes())));
to_publish.push(super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?);
}
for outer in &to_publish {
transport.publish_durable(outer, &community.relays).await?;
}
if session.is_valid() {
for (entity_hex, hash, inner_id) in &heads {
let _ = match inner_id {
Some(id) => crate::db::community::set_edition_head_with_id(&cid, entity_hex, 1, hash, id),
None => crate::db::community::set_edition_head(&cid, entity_hex, 1, hash),
};
}
let roster = super::roles::CommunityRoles { roles: vec![admin], grants: Vec::new() };
let _ = crate::db::community::set_community_roles(&cid, &roster, created as i64);
}
Ok(community)
}
pub async fn send_message<T: Transport + ?Sized>(
transport: &T,
community: &Community,
channel: &Channel,
author: &Keys,
content: &str,
ms: u64,
) -> Result<Event, String> {
let session = SessionGuard::capture();
let inner = super::envelope::build_inner_event(author.public_key(), &channel.id, channel.epoch, content, ms, None)
.sign_with_keys(author)
.map_err(|e| e.to_string())?;
let (outer, ephemeral) = publish_signed_message(transport, community, channel, &inner, false).await?;
if !session.is_valid() {
return Err("account changed during send; not persisting message key".to_string());
}
crate::db::community::store_message_key(&inner.id.to_hex(), &outer.id.to_hex(), &ephemeral, &community.relays)?;
Ok(outer)
}
pub async fn send_signed_message<T: Transport + ?Sized>(
transport: &T,
community: &Community,
channel: &Channel,
inner: &Event,
) -> Result<Event, String> {
let session = SessionGuard::capture();
let (outer, ephemeral) = publish_signed_message(transport, community, channel, inner, false).await?;
if !session.is_valid() {
return Err("account changed during send; not persisting message key".to_string());
}
crate::db::community::store_message_key(&inner.id.to_hex(), &outer.id.to_hex(), &ephemeral, &community.relays)?;
Ok(outer)
}
pub async fn build_presence(
channel: &Channel,
joined: bool,
attribution: Option<(String, Option<String>)>,
) -> Result<nostr_sdk::Event, String> {
let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let content = match (joined, attribution) {
(false, _) => "leave".to_string(),
(true, Some((by, label))) => serde_json::json!({ "by": by, "l": label }).to_string(),
(true, None) => "join".to_string(),
};
let unsigned = super::envelope::build_inner_typed(
author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, &content, ms, None, &[],
);
let signer = active_signer().await?;
unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign presence: {e}"))
}
pub async fn publish_presence_event<T: Transport + ?Sized>(
transport: &T,
community: &Community,
channel: &Channel,
inner: &nostr_sdk::Event,
) -> Result<(), String> {
let _ = publish_signed_message(transport, community, channel, inner, true).await?;
Ok(())
}
pub async fn publish_presence<T: Transport + ?Sized>(
transport: &T,
community: &Community,
channel: &Channel,
joined: bool,
attribution: Option<(String, Option<String>)>,
) -> Result<(), String> {
let inner = build_presence(channel, joined, attribution).await?;
publish_presence_event(transport, community, channel, &inner).await
}
pub async fn publish_webxdc_signal<T: Transport + ?Sized>(
transport: &T,
community: &Community,
channel: &Channel,
topic_id: &str,
node_addr: Option<&str>,
) -> Result<(), String> {
let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
let unsigned = super::envelope::build_inner_typed(
author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_WEBXDC, &content, ms, None, &[],
);
let signer = active_signer().await?;
let inner = unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign webxdc signal: {e}"))?;
let _ = publish_signed_message(transport, community, channel, &inner, true).await?;
Ok(())
}
pub async fn publish_typing_signal<T: Transport + ?Sized>(
transport: &T,
community: &Community,
channel: &Channel,
) -> Result<(), String> {
let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let unsigned = super::envelope::build_inner_typed(
author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_TYPING, "typing", ms, None, &[],
);
let signer = active_signer().await?;
let inner = unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign typing signal: {e}"))?;
let _ = publish_signed_message(transport, community, channel, &inner, false).await?;
Ok(())
}
pub async fn persist_webxdc_signal(
channel_hex: &str,
npub: &str,
topic_id: &str,
node_addr: Option<&str>,
event_id: &str,
created_at: u64,
) {
if crate::db::events::event_exists(event_id).unwrap_or(true) {
return;
}
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let created_at = created_at.min(now_secs + 300);
let Ok(chat_id) = crate::db::id_cache::get_or_create_chat_id(channel_hex) else { return };
let mut tags = vec![
vec!["webxdc-topic".to_string(), topic_id.to_string()],
vec!["d".to_string(), "vector-webxdc-peer".to_string()],
];
if let Some(addr) = node_addr {
tags.push(vec!["webxdc-node-addr".to_string(), addr.to_string()]);
}
let event = crate::stored_event::StoredEvent {
id: event_id.to_string(),
kind: crate::stored_event::event_kind::APPLICATION_SPECIFIC,
chat_id,
user_id: None,
content: if node_addr.is_some() { "peer-advertisement" } else { "peer-left" }.to_string(),
tags,
reference_id: Some(topic_id.to_string()),
created_at,
received_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64,
mine: false,
pending: false,
failed: false,
wrapper_event_id: None,
npub: Some(npub.to_string()),
preview_metadata: None,
};
if let Err(e) = crate::db::events::save_event(&event).await {
crate::log_warn!("[community] failed to persist webxdc peer signal: {e}");
}
}
async fn strip_member_roles_on_removal<T: Transport + ?Sized>(
transport: &T,
community: &Community,
member_hex: &str,
) {
let cid = community.id.to_hex();
let roster = match crate::db::community::get_community_roles(&cid) {
Ok(r) => r,
Err(_) => return,
};
let held: Vec<String> = roster
.grants
.iter()
.find(|g| g.member == member_hex)
.map(|g| g.role_ids.clone())
.unwrap_or_default();
if held.is_empty() {
return; }
for role_id in &held {
if caller_can_manage_role(community, &roster, role_id, member_hex).is_err() {
crate::log_warn!(
"removal: not authorized to revoke role {role_id} of {member_hex}; leaving the grant (kick/ban still neutralizes)"
);
return;
}
}
if let Err(e) = set_member_grant(transport, community, member_hex, Vec::new()).await {
crate::log_warn!("removal: role-strip publish failed for {member_hex}: {e}");
}
}
pub async fn publish_kick<T: Transport + ?Sized>(
transport: &T,
community: &Community,
channel: &Channel,
target_hex: &str,
) -> Result<String, String> {
let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
let me = author_pk.to_hex();
let cid = community.id.to_hex();
{
let owner = proven_owner_hex(community);
let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
if !roster.can_act_on_member(&me, owner.as_deref(), target_hex, super::roles::Permissions::KICK) {
return Err("you can't kick a member who outranks you (or the owner)".to_string());
}
}
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let citation = authority_citation(community, &me);
let extra: Vec<nostr_sdk::prelude::Tag> = citation.iter().map(|c| c.to_tag()).collect();
let unsigned = super::envelope::build_inner_full(
author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_KICK, target_hex, ms, None, &[], &extra,
);
let signer = active_signer().await?;
let inner = unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign kick: {e}"))?;
publish_signed_message(transport, community, channel, &inner, true).await?;
strip_member_roles_on_removal(transport, community, target_hex).await;
Ok(inner.id.to_hex())
}
pub async fn publish_banlist<T: Transport + ?Sized>(
transport: &T,
community: &Community,
banned_hex: &[String],
) -> Result<(), String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
let signer = active_signer().await?;
let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the banlist edition")?;
{
let me = actor_pk.to_hex();
let owner = proven_owner_hex(community);
let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
let current: std::collections::HashSet<String> =
crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
let next: std::collections::HashSet<&str> = banned_hex.iter().map(|s| s.as_str()).collect();
let added = banned_hex.iter().filter(|n| !current.contains(n.as_str()));
let removed = current.iter().filter(|n| !next.contains(n.as_str()));
for target in added.chain(removed) {
if !roster.can_act_on_member(&me, owner.as_deref(), target, super::roles::Permissions::BAN) {
return Err("you can't ban or unban a member who outranks you (or the owner)".to_string());
}
}
}
{
let prev: std::collections::HashSet<String> =
crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
let adds = banned_hex.iter().any(|n| !prev.contains(n.as_str()));
let cut_needed = (adds || crate::db::community::get_read_cut_pending(&cid)?) && !is_public(community)?;
if cut_needed && crate::state::MY_SECRET_KEY.to_keys().is_none() {
return Err("Banning someone from a private community cuts their read access, which needs a key rotation your account can't perform: it signs remotely (a NIP-46 bunker), and a rotation requires a local key. Ask a community admin who holds a local key to carry out the ban.".to_string());
}
}
let entity_id = super::derive::banlist_locator(&community.id);
let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
Some((v, h)) => (v + 1, Some(h)),
None => (1, None),
};
let created_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let citation = authority_citation(community, &actor_pk.to_hex());
let unsigned = super::roster::build_banlist_edition_unsigned(actor_pk, &community.id, banned_hex, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign banlist edition: {e}"))?;
let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
let newly_added: Vec<String> = {
let prev: std::collections::HashSet<String> =
crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
banned_hex.iter().filter(|n| !prev.contains(n.as_str())).cloned().collect()
};
let newly_banned = !newly_added.is_empty();
transport.publish_durable(&outer, &community.relays).await?;
if session.is_valid() {
crate::db::community::set_community_banlist(&cid, banned_hex, created_at as i64)?;
crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
}
if session.is_valid() {
for member_hex in &newly_added {
strip_member_roles_on_removal(transport, community, member_hex).await;
}
}
let need_cut = (newly_banned || crate::db::community::get_read_cut_pending(&cid)?)
&& session.is_valid()
&& !is_public(community)?;
if need_cut {
run_read_cut(transport, community, newly_banned).await?;
}
Ok(())
}
pub fn am_i_banned(community: &Community) -> bool {
let me = match crate::state::my_public_key() {
Some(p) => p.to_hex(),
None => return false,
};
crate::db::community::get_community_banlist(&community.id.to_hex())
.unwrap_or_default()
.iter()
.any(|b| b == &me)
}
pub async fn retry_pending_read_cut<T: Transport + ?Sized>(
transport: &T,
community: &Community,
) -> Result<(), String> {
let cid = community.id.to_hex();
if !crate::db::community::get_read_cut_pending(&cid)? {
return Ok(());
}
if is_public(community)? {
crate::db::community::set_read_cut_pending(&cid, false)?; return Ok(());
}
let fresh = crate::db::community::load_community(&community.id)?.ok_or("community no longer present")?;
run_read_cut(transport, &fresh, false).await
}
async fn fetch_control_folded<T: Transport + ?Sized>(
transport: &T,
community: &Community,
) -> Result<super::roster::FoldedRoster, String> {
let z_tags = vec![super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch)];
let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags, ..Default::default() };
let raw = transport.fetch(&query, &community.relays).await?;
let inner_editions: Vec<Event> = raw
.iter()
.take(super::roster::MAX_CONTROL_EDITIONS)
.filter_map(|ev| super::roster::open_control_edition(ev, &community.server_root_key).ok())
.collect();
let fetched = inner_editions.len();
let current_epoch = community.server_root_epoch.0;
let floors: std::collections::HashMap<String, (u64, [u8; 32])> =
crate::db::community::get_all_edition_heads_epoched(&community.id.to_hex())?
.into_iter()
.filter(|(_, (epoch, _, _))| *epoch == current_epoch)
.map(|(entity, (_epoch, version, hash))| (entity, (version, hash)))
.collect();
let mut folded = super::roster::fold_roster(&inner_editions, &community.id, &floors);
folded.fetched = fetched; Ok(folded)
}
pub async fn fetch_and_apply_control<T: Transport + ?Sized>(
transport: &T,
community: &Community,
) -> Result<usize, String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
if crate::db::community::get_community_dissolved(&cid)? {
return Ok(0);
}
let folded = fetch_control_folded(transport, community).await?;
if !session.is_valid() {
return Err("account changed during control fetch".to_string());
}
if let Some(owner) = proven_owner_hex(community) {
let by_fold = folded.dissolved_by.iter().any(|s| s.to_hex() == owner);
let by_probe = !by_fold && dissolved_tombstone_present(transport, community, &owner).await;
if by_fold || by_probe {
let _ = fetch_and_apply_banlist_inner(transport, community, Some(folded.clone())).await;
let _ = fetch_and_apply_roles_inner(transport, community, Some(folded.clone())).await;
let _ = fetch_and_apply_invite_links_inner(transport, community, Some(folded.clone())).await;
let _ = fetch_and_apply_metadata_inner(transport, community, Some(folded.clone())).await;
if session.is_valid() {
crate::db::community::set_community_dissolved(&cid)?;
crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid }));
}
return Ok(folded.fetched);
}
}
let fetched = folded.fetched;
let _ = fetch_and_apply_banlist_inner(transport, community, Some(folded.clone())).await;
let _ = fetch_and_apply_roles_inner(transport, community, Some(folded.clone())).await;
let _ = fetch_and_apply_invite_links_inner(transport, community, Some(folded.clone())).await;
let _ = fetch_and_apply_metadata_inner(transport, community, Some(folded)).await;
Ok(fetched)
}
pub async fn fetch_and_apply_banlist<T: Transport + ?Sized>(
transport: &T,
community: &Community,
) -> Result<Vec<String>, String> {
fetch_and_apply_banlist_inner(transport, community, None).await
}
async fn fetch_and_apply_banlist_inner<T: Transport + ?Sized>(
transport: &T,
community: &Community,
prefolded: Option<super::roster::FoldedRoster>,
) -> Result<Vec<String>, String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
let folded = match prefolded {
Some(f) => f,
None => fetch_control_folded(transport, community).await?,
};
let owner = proven_owner_hex(community);
let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
if !session.is_valid() {
return Err("account changed during banlist fetch".to_string());
}
if let (Some(author), Some(head)) = (folded.banlist_author, &folded.banlist_head) {
let author_hex = author.to_hex();
let held: std::collections::HashSet<String> =
crate::db::community::get_community_banlist(&cid)?.into_iter().collect();
let next: std::collections::HashSet<&str> = folded.banned.iter().map(|s| s.as_str()).collect();
let added = folded.banned.iter().filter(|n| !held.contains(n.as_str()));
let removed = held.iter().filter(|n| !next.contains(n.as_str()));
let citation = folded.banlist_head.as_ref().and_then(|h| h.citation.as_ref());
let banner_grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(&community.id, &author.to_bytes()));
let pinned = super::roster::authority_citation_satisfied(&folded.heads, owner.as_deref(), &author_hex, &banner_grant_hex, citation);
let authed = pinned
&& added.chain(removed).all(|target| {
authorized.can_act_on_member(&author_hex, owner.as_deref(), target, super::roles::Permissions::BAN)
});
let held_version = crate::db::community::get_edition_head(&cid, &head.entity_hex)?.map(|(v, _)| v).unwrap_or(0);
if authed && head.version > held_version {
crate::db::community::set_community_banlist(&cid, &folded.banned, head.version as i64)?;
crate::db::community::set_edition_head(&cid, &head.entity_hex, head.version, &head.self_hash)?;
return Ok(folded.banned);
}
}
crate::db::community::get_community_banlist(&cid)
}
pub async fn set_member_grant<T: Transport + ?Sized>(
transport: &T,
community: &Community,
member_hex: &str,
role_ids: Vec<String>,
) -> Result<(), String> {
let session = SessionGuard::capture();
let signer = active_signer().await?;
let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the grant edition")?;
let cid = community.id.to_hex();
let grant = super::roles::MemberGrant { member: member_hex.to_string(), role_ids };
let member_bytes = crate::simd::hex::hex_to_bytes_32(member_hex);
let entity_id = super::derive::grant_locator(&community.id, &member_bytes);
let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
Some((v, h)) => (v + 1, Some(h)),
None => (1, None),
};
let created_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let citation = authority_citation(community, &actor_pk.to_hex());
let unsigned = super::roster::build_grant_edition_unsigned(actor_pk, &community.id, &grant, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign grant edition: {e}"))?;
let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
let is_full_revoke = grant.role_ids.is_empty();
let mut roster = crate::db::community::get_community_roles(&cid)?;
roster.grants.retain(|g| g.member != member_hex);
if !grant.role_ids.is_empty() {
roster.grants.push(grant);
}
transport.publish_durable(&outer, &community.relays).await?;
if session.is_valid() {
crate::db::community::set_community_roles(&cid, &roster, created_at as i64)?;
crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
}
if is_full_revoke && session.is_valid() {
if let Ok(folded) = fetch_control_folded(transport, community).await {
if session.is_valid() {
let current = crate::db::community::load_community(&community.id)?.unwrap_or_else(|| community.clone());
if folded.root_author.map(|a| a.to_hex()).as_deref() == Some(member_hex) {
if let Some(meta) = &folded.root_meta {
let mut c = current.clone();
c.name = meta.name.clone();
c.description = meta.description.clone();
c.icon = meta.icon.clone();
c.banner = meta.banner.clone();
let _ = republish_community_metadata(transport, &c).await;
}
}
for cm in &folded.channel_meta {
if cm.author.to_hex() == member_hex
&& current.channels.iter().any(|ch| ch.id.0 == cm.channel_id)
{
let _ = republish_channel_metadata(
transport, ¤t, &crate::community::ChannelId(cm.channel_id), &cm.meta.name,
).await;
}
}
}
}
}
Ok(())
}
pub fn is_proven_owner(community: &Community) -> bool {
match crate::state::my_public_key() {
Some(me) => proven_owner_hex(community).as_deref() == Some(me.to_hex().as_str()),
None => false,
}
}
pub fn caller_can_manage_roles(community: &Community) -> bool {
let me = match crate::state::my_public_key() {
Some(p) => p,
None => return false,
};
let cid = community.id.to_hex();
let is_owner = community
.owner_attestation
.as_ref()
.and_then(|a| super::owner::verify_owner_attestation(a, &cid))
.map(|pk| pk == me)
.unwrap_or(false);
if is_owner {
return true; }
crate::db::community::get_community_roles(&cid)
.unwrap_or_default()
.has_permission(&me.to_hex(), super::roles::Permissions::MANAGE_ROLES)
}
pub fn caller_has_permission(community: &Community, permission: u64) -> bool {
let me = match crate::state::my_public_key() {
Some(p) => p,
None => return false,
};
crate::db::community::get_community_roles(&community.id.to_hex())
.unwrap_or_default()
.is_authorized(&me.to_hex(), proven_owner_hex(community).as_deref(), permission)
}
pub fn caller_can_manage_role_id(community: &Community, role_id: &str) -> bool {
let me = match crate::state::my_public_key() {
Some(p) => p.to_hex(),
None => return false,
};
let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
let position = match roster.role(role_id) {
Some(r) => r.position,
None => return false,
};
roster.can_manage_position(&me, proven_owner_hex(community).as_deref(), position)
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct CommunityCapabilities {
pub manage_metadata: bool,
pub manage_channels: bool,
pub create_invite: bool,
pub kick: bool,
pub ban: bool,
pub manage_messages: bool,
pub manage_roles: bool,
}
pub fn caller_capabilities(community: &Community) -> CommunityCapabilities {
use super::roles::Permissions as P;
let me_hex = match crate::state::my_public_key() {
Some(p) => p.to_hex(),
None => return CommunityCapabilities::default(),
};
let owner = proven_owner_hex(community);
let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
let has = |bit: u64| roster.is_authorized(&me_hex, owner.as_deref(), bit);
CommunityCapabilities {
manage_metadata: has(P::MANAGE_METADATA),
manage_channels: has(P::MANAGE_CHANNELS),
create_invite: has(P::CREATE_INVITE),
kick: has(P::KICK),
ban: has(P::BAN),
manage_messages: has(P::MANAGE_MESSAGES),
manage_roles: has(P::MANAGE_ROLES),
}
}
fn authority_citation(community: &Community, actor_hex: &str) -> Option<super::edition::AuthorityCitation> {
if proven_owner_hex(community).as_deref() == Some(actor_hex) {
return None;
}
let cid = community.id.to_hex();
let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
let entity_id = super::derive::grant_locator(&community.id, &actor_bytes);
let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
crate::db::community::get_edition_head(&cid, &entity_hex)
.ok()
.flatten()
.map(|(version, edition_hash)| super::edition::AuthorityCitation { entity_id, version, edition_hash })
}
fn proven_owner_hex(community: &Community) -> Option<String> {
let cid = community.id.to_hex();
community
.owner_attestation
.as_ref()
.and_then(|a| super::owner::verify_owner_attestation(a, &cid))
.map(|pk| pk.to_hex())
}
pub fn can_moderation_hide(community: &Community, actor_hex: &str, author_hex: &str) -> bool {
let to_hex = |s: &str| nostr_sdk::PublicKey::parse(s).map(|pk| pk.to_hex()).unwrap_or_else(|_| s.to_string());
let actor = to_hex(actor_hex);
let author = to_hex(author_hex);
let owner = proven_owner_hex(community);
let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
roster.can_act_on_member(&actor, owner.as_deref(), &author, super::roles::Permissions::MANAGE_MESSAGES)
}
fn rotator_is_authorized(
cid: &str,
roster: &super::roles::CommunityRoles,
owner_hex: Option<&str>,
rotator_hex: &str,
permission: u64,
) -> bool {
if owner_hex != Some(rotator_hex)
&& crate::db::community::get_community_banlist(cid)
.unwrap_or_default()
.iter()
.any(|b| b == rotator_hex)
{
return false;
}
roster.is_authorized(rotator_hex, owner_hex, permission)
}
fn caller_can_manage_role(
community: &Community,
roster: &super::roles::CommunityRoles,
role_id: &str,
member_hex: &str,
) -> Result<(), String> {
let me = crate::state::my_public_key().ok_or("no active identity")?.to_hex();
let owner = proven_owner_hex(community);
let owner_ref = owner.as_deref();
let role = roster.role(role_id).ok_or("no such role")?;
if !roster.can_manage_position(&me, owner_ref, role.position) {
return Err("you can only manage roles below your own".to_string());
}
if !roster.can_manage_member(&me, owner_ref, member_hex) {
return Err("you can't manage a member who outranks you".to_string());
}
Ok(())
}
pub async fn grant_role<T: Transport + ?Sized>(
transport: &T,
community: &Community,
member: nostr_sdk::prelude::PublicKey,
role_id: &str,
) -> Result<(), String> {
let cid = community.id.to_hex();
let member_hex = member.to_hex();
let roster = crate::db::community::get_community_roles(&cid)?;
caller_can_manage_role(community, &roster, role_id, &member_hex)?;
let mut role_ids: Vec<String> = roster
.grants
.iter()
.find(|g| g.member == member_hex)
.map(|g| g.role_ids.clone())
.unwrap_or_default();
if !role_ids.iter().any(|r| r == role_id) {
role_ids.push(role_id.to_string());
}
set_member_grant(transport, community, &member_hex, role_ids).await
}
pub async fn revoke_role<T: Transport + ?Sized>(
transport: &T,
community: &Community,
member: nostr_sdk::prelude::PublicKey,
role_id: &str,
) -> Result<(), String> {
let cid = community.id.to_hex();
let member_hex = member.to_hex();
let roster = crate::db::community::get_community_roles(&cid)?;
caller_can_manage_role(community, &roster, role_id, &member_hex)?;
let role_ids: Vec<String> = roster
.grants
.iter()
.find(|g| g.member == member_hex)
.map(|g| g.role_ids.iter().filter(|r| r.as_str() != role_id).cloned().collect())
.unwrap_or_default();
set_member_grant(transport, community, &member_hex, role_ids).await
}
pub async fn fetch_and_apply_roles<T: Transport + ?Sized>(
transport: &T,
community: &Community,
) -> Result<super::roles::CommunityRoles, String> {
fetch_and_apply_roles_inner(transport, community, None).await
}
async fn fetch_and_apply_roles_inner<T: Transport + ?Sized>(
transport: &T,
community: &Community,
prefolded: Option<super::roster::FoldedRoster>,
) -> Result<super::roles::CommunityRoles, String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
let folded = match prefolded {
Some(f) => f,
None => fetch_control_folded(transport, community).await?,
};
if !session.is_valid() {
return Err("account changed during roles fetch".to_string());
}
for head in &folded.heads {
crate::db::community::set_edition_head(&cid, &head.entity_hex, head.version, &head.self_hash)?;
}
if folded.heads.is_empty() {
return crate::db::community::get_community_roles(&cid);
}
let authorized = super::roster::authorize_delegation(&folded, proven_owner_hex(community).as_deref());
crate::db::community::set_community_roles(&cid, &authorized, 0)?;
Ok(authorized)
}
pub async fn publish_owner_hide<T: Transport + ?Sized>(
transport: &T,
community: &Community,
channel: &Channel,
target_message_id: &str,
) -> Result<(), String> {
let signer = active_signer().await?;
let me_pk = crate::state::my_public_key().ok_or("no local identity to sign the hide")?;
let me = me_pk.to_hex();
{
let target_author = {
let st = crate::state::STATE.lock().await;
st.find_message(target_message_id).and_then(|(_, m)| m.npub)
};
let author = target_author
.ok_or("can't resolve the target message's author to authorize the hide")?;
if !can_moderation_hide(community, &me, &author) {
return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
}
}
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let citation = authority_citation(community, &me);
let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
let inner = super::envelope::build_inner_full(
me_pk, &channel.id, channel.epoch,
event_kind::COMMUNITY_DELETE, "", ms, Some(target_message_id), &[], &extra,
)
.sign(&signer)
.await
.map_err(|e| format!("sign hide: {e}"))?;
let _ = publish_signed_message(transport, community, channel, &inner, true).await?;
Ok(())
}
pub async fn delete_message<T: Transport + ?Sized>(
transport: &T,
message_id: &str,
) -> Result<(), String> {
let session = SessionGuard::capture();
if !session.is_valid() {
return Err("account changed; aborting delete".to_string());
}
let (ephemeral, outer_event_id_hex, relays) = match crate::db::community::get_message_key(message_id)? {
Some(v) => v,
None => {
return Err("no retained key for this message (not yours, or already deleted)".to_string())
}
};
let id = EventId::from_hex(&outer_event_id_hex).map_err(|e| e.to_string())?;
delete_own_message(transport, &relays, &ephemeral, id).await?;
crate::db::community::delete_message_key(message_id)?;
Ok(())
}
pub fn accept_invite(invite: &CommunityInvite) -> Result<Community, String> {
let session = SessionGuard::capture();
let community = super::invite::accept_invite(invite)?;
match crate::db::community::load_community(&community.id)? {
Some(existing) => {
if is_proven_owner(&existing) {
return Err("you already own this Community".to_string());
}
if existing.server_root_key.as_bytes() != community.server_root_key.as_bytes() {
return Err(
"invite reuses a known Community id under a different authority — rejected"
.to_string(),
);
}
}
None => enforce_community_cap()?,
}
if !session.is_valid() {
return Err("account changed during invite accept".to_string());
}
crate::db::community::save_community(&community)?;
Ok(community)
}
pub async fn preload_community(invite: &super::invite::CommunityInvite) {
let Ok(community) = super::invite::accept_invite(invite) else { return };
let Some(channel) = community.channels.first() else { return };
let cid = community.id.to_hex();
crate::community::cache::begin_preload(&cid);
let transport = super::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
match super::send::fetch_channel_page(&transport, &community, channel, None, None, 50).await {
Ok(page) if !page.is_empty() => crate::community::cache::finish_preload(&cid, page),
_ => crate::community::cache::abort_preload(&cid),
}
let prune_relays = community.relays.clone();
let prune_id = community.id;
let guard = crate::state::SessionGuard::capture();
tokio::spawn(async move {
tokio::time::sleep(crate::community::cache::PRELOAD_TTL).await;
if !guard.is_valid() {
return;
}
if matches!(crate::db::community::load_community(&prune_id), Ok(Some(_))) {
return;
}
crate::community::cache::abort_preload(&prune_id.to_hex());
super::transport::prune_unneeded_community_relays(&prune_relays).await;
});
}
pub async fn republish_community_metadata<T: Transport + ?Sized>(
transport: &T,
community: &Community,
) -> Result<(), String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
let signer = active_signer().await?;
let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the metadata edition")?;
let owner = proven_owner_hex(community);
let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
if !roster.is_authorized(&actor_pk.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_METADATA) {
return Err("only a member with manage-metadata authority can edit the community".to_string());
}
let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &cid)? {
Some((v, h)) => (v + 1, Some(h)),
None => (1, None),
};
let created = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let meta = super::metadata::CommunityMetadata::of(community);
let citation = authority_citation(community, &actor_pk.to_hex());
let unsigned = super::roster::build_community_root_edition_unsigned(actor_pk, &community.id, &meta, version, prev_hash.as_ref(), created, citation.as_ref())?;
let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign community-root edition: {e}"))?;
let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
transport.publish_durable(&outer, &community.relays).await?;
if session.is_valid() {
crate::db::community::save_community(community)?;
let h = super::version::edition_hash(&community.id.0, version, prev_hash.as_ref(), inner.content.as_bytes());
crate::db::community::set_edition_head_with_id(&cid, &cid, version, &h, &inner.id.to_bytes())?;
}
Ok(())
}
pub async fn republish_channel_metadata<T: Transport + ?Sized>(
transport: &T,
community: &Community,
channel_id: &crate::community::ChannelId,
new_name: &str,
) -> Result<(), String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
let ch_hex = channel_id.to_hex();
if !community.channels.iter().any(|c| &c.id == channel_id) {
return Err("no such channel in this community".to_string());
}
let signer = active_signer().await?;
let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the channel metadata edition")?;
let owner = proven_owner_hex(community);
let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
if !roster.is_authorized(&actor_pk.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
return Err("only a member with manage-channels authority can rename a channel".to_string());
}
let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &ch_hex)? {
Some((v, h)) => (v + 1, Some(h)),
None => (1, None),
};
let created = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let meta = super::metadata::ChannelMetadata { name: new_name.to_string() };
let citation = authority_citation(community, &actor_pk.to_hex());
let unsigned = super::roster::build_channel_metadata_edition_unsigned(actor_pk, channel_id, &meta, version, prev_hash.as_ref(), created, citation.as_ref())?;
let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign channel-metadata edition: {e}"))?;
let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
transport.publish_durable(&outer, &community.relays).await?;
if session.is_valid() {
let mut current = crate::db::community::load_community(&community.id)?.ok_or("community no longer present")?;
if let Some(ch) = current.channels.iter_mut().find(|c| &c.id == channel_id) {
ch.name = new_name.to_string();
}
crate::db::community::save_community(¤t)?;
let h = super::version::edition_hash(&channel_id.0, version, prev_hash.as_ref(), inner.content.as_bytes());
crate::db::community::set_edition_head_with_id(&cid, &ch_hex, version, &h, &inner.id.to_bytes())?;
}
Ok(())
}
fn generate_invite_label() -> String {
use rand::Rng;
const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let mut rng = rand::thread_rng();
(0..6).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect()
}
pub async fn create_public_invite<T: Transport + ?Sized>(
transport: &T,
community: &Community,
expires_at: Option<u64>,
label: Option<String>,
) -> Result<(String, String), String> {
if !caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
return Err("you need the create-invite permission to mint a public invite".to_string());
}
let session = SessionGuard::capture();
let existing = crate::db::community::list_public_invites(&community.id.to_hex()).unwrap_or_default();
let label_taken = |cand: &str| {
existing.iter().any(|r| r.label.as_deref().map(|e| e.eq_ignore_ascii_case(cand)).unwrap_or(false))
};
let label = match label {
Some(l) if !l.trim().is_empty() => {
let l = l.trim().to_string();
if label_taken(&l) {
return Err(format!("You already have an invite link labeled \u{201c}{l}\u{201d}. Pick a different label."));
}
Some(l)
}
_ => {
let mut l = generate_invite_label();
while label_taken(&l) {
l = generate_invite_label();
}
Some(l)
}
};
let creator_npub = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok());
let token = public_invite::new_token();
let event = build_public_invite_event(community, &token, expires_at, creator_npub, label.clone()).map_err(|e| e.to_string())?;
transport.publish_durable(&event, &community.relays).await?;
if !session.is_valid() {
return Err("account changed during public invite creation".to_string());
}
let token_hex = crate::simd::hex::bytes_to_hex_32(&token);
let url = public_invite::encode_invite_url(&community.relays, &token);
crate::db::community::save_public_invite(
&token_hex,
&community.id.to_hex(),
&url,
expires_at.map(|e| e as i64),
label.as_deref(),
)?;
super::invite_list::add_invite(super::invite_list::InviteEntry {
token: token_hex.clone(),
community_id: community.id.to_hex(),
url: url.clone(),
label: label.clone(),
created_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
expires_at,
});
republish_my_invite_links(transport, community).await?;
Ok((token_hex, url))
}
pub async fn latest_invite_preview<T: Transport + ?Sized>(
transport: &T,
bundle: &public_invite::PublicInviteBundle,
) -> public_invite::PublicInvitePreview {
let snapshot = bundle.preview.clone();
let Ok(community) = super::invite::accept_invite(&bundle.join) else {
return snapshot;
};
let Ok(folded) = fetch_control_folded(transport, &community).await else {
return snapshot;
};
let owner = proven_owner_hex(&community);
let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
match folded.root_candidates.iter().find(|c| {
authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_METADATA)
}) {
Some(c) => public_invite::PublicInvitePreview {
name: c.meta.name.clone(),
description: c.meta.description.clone(),
icon: c.meta.icon.clone(),
},
None => snapshot,
}
}
pub async fn fetch_public_invite<T: Transport + ?Sized>(
transport: &T,
relays: &[String],
token: &[u8; 32],
) -> Result<PublicInviteBundle, String> {
let query = Query {
kinds: vec![event_kind::APPLICATION_SPECIFIC],
d_tags: vec![locator_hex(token)],
..Default::default()
};
let events = transport.fetch(&query, relays).await?;
let (mut bundle_at, mut bundle, mut revoked_at) = (0u64, None, None::<u64>);
for ev in &events {
match parse_public_invite_event(ev, token) {
Ok(b) => if bundle.is_none() || ev.created_at.as_secs() > bundle_at {
bundle_at = ev.created_at.as_secs();
bundle = Some(b);
},
Err(super::public_invite::PublicInviteError::Revoked) => {
let at = ev.created_at.as_secs();
if revoked_at.map_or(true, |r| at > r) { revoked_at = Some(at); }
}
Err(_) => {} }
}
match (bundle, revoked_at) {
(Some(b), Some(r)) if bundle_at > r => Ok(b), (_, Some(_)) => Err("this invite was revoked".to_string()),
(Some(b), None) => Ok(b),
(None, None) => Err("no public invite found at that link (revoked, never posted, or shadowed)".to_string()),
}
}
pub fn accept_public_invite(bundle: &PublicInviteBundle, now_secs: u64) -> Result<Community, String> {
if bundle.is_expired(now_secs) {
return Err("this invite link has expired".to_string());
}
let mut community = accept_invite(&bundle.join)?;
if bundle.preview.description.is_some() || bundle.preview.icon.is_some() {
community.description = bundle.preview.description.clone();
community.icon = bundle.preview.icon.clone();
crate::db::community::save_community(&community)?;
}
Ok(community)
}
pub async fn revoke_public_invite<T: Transport + ?Sized>(
transport: &T,
community: &Community,
token: &[u8; 32],
) -> Result<(), String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
let token_hex = crate::simd::hex::bytes_to_hex_32(token);
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
if !crate::db::community::list_public_invites(&cid)?.iter().any(|r| r.token == token_hex) {
return Ok(());
}
let my_locators_before: Vec<String> = crate::db::community::list_public_invites(&cid)?
.iter()
.filter(|r| r.expires_at.map_or(true, |e| (e as u64) > now))
.map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
.collect();
let _ = fetch_and_apply_invite_links(transport, community).await;
if !session.is_valid() {
return Err("account changed during invite revoke".to_string());
}
let this_locator = public_invite::locator_hex(token);
let cached_aggregate: std::collections::BTreeSet<String> =
crate::db::community::get_community_invite_registry(&cid)?.into_iter().collect();
let my_before: std::collections::BTreeSet<String> = my_locators_before.iter().cloned().collect();
let others: std::collections::BTreeSet<String> = cached_aggregate.difference(&my_before).cloned().collect();
let my_after: std::collections::BTreeSet<String> =
my_before.iter().filter(|l| **l != this_locator).cloned().collect();
let would_empty_aggregate = others.is_empty() && my_after.is_empty();
if would_empty_aggregate && crate::state::MY_SECRET_KEY.to_keys().is_none() {
return Err("Revoking this last invite link makes the community private, which re-keys it so link-joined lurkers lose access. Your account signs remotely (a NIP-46 bunker) and can't perform that rotation. Ask a community admin who holds a local key to privatize the community.".to_string());
}
if let Ok(tombstone) = public_invite::build_public_invite_tombstone(token) {
let _ = transport.publish_durable(&tombstone, &community.relays).await;
}
if !session.is_valid() {
return Err("account changed during invite revoke".to_string());
}
crate::db::community::delete_public_invite(&token_hex)?;
super::invite_list::revoke_invite(&token_hex, &cid);
republish_my_invite_links(transport, community).await?;
if session.is_valid() {
let aggregate_after: Vec<String> = others.union(&my_after).cloned().collect();
crate::db::community::set_community_invite_registry(&cid, &aggregate_after)?;
}
if would_empty_aggregate {
run_read_cut(transport, community, true).await?;
}
Ok(())
}
async fn dissolved_tombstone_present<T: Transport + ?Sized>(transport: &T, community: &Community, owner_hex: &str) -> bool {
let z = super::derive::dissolved_pseudonym(&community.id);
let q = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
for ev in transport.fetch(&q, &community.relays).await.unwrap_or_default() {
if super::roster::dissolved_tombstone_signer(&ev, &community.id).map(|s| s.to_hex()) == Some(owner_hex.to_string()) {
return true;
}
}
false
}
pub async fn dissolve_community<T: Transport + ?Sized>(
transport: &T,
community: &Community,
) -> Result<(), String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
if !is_proven_owner(community) {
return Err("only the community owner can dissolve (delete) the community".to_string());
}
let signer = active_signer().await?;
let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the dissolution")?;
let created_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let unsigned = super::roster::build_group_dissolved_edition_unsigned(actor_pk, &community.id, created_at);
let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign dissolution tombstone: {e}"))?;
let stable = super::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id)?;
transport.publish_durable(&stable, &community.relays).await?;
if let Ok(outer) = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch) {
let _ = transport.publish_durable(&outer, &community.relays).await;
}
if !session.is_valid() {
return Err("account changed during dissolution".to_string());
}
if caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
let _ = publish_my_invite_links(transport, community, &[]).await;
if let Ok(records) = crate::db::community::list_public_invites(&cid) {
for r in records {
let token = crate::simd::hex::hex_to_bytes_32(&r.token);
if let Ok(tombstone) = public_invite::build_public_invite_tombstone(&token) {
let _ = transport.publish_durable(&tombstone, &community.relays).await;
}
let _ = crate::db::community::delete_public_invite(&r.token);
}
}
}
if !session.is_valid() {
return Err("account changed during dissolution".to_string());
}
crate::db::community::set_community_dissolved(&cid)?;
Ok(())
}
pub async fn publish_my_invite_links<T: Transport + ?Sized>(
transport: &T,
community: &Community,
my_locators: &[String],
) -> Result<(), String> {
let session = SessionGuard::capture();
if !caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
return Err("you need the create-invite permission to publish invite links".to_string());
}
let cid = community.id.to_hex();
let signer = active_signer().await?;
let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the invite links")?;
let entity_id = super::derive::invite_links_locator(&community.id, &actor_pk.to_bytes());
let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
Some((v, h)) => (v + 1, Some(h)),
None => (1, None),
};
let created_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let citation = authority_citation(community, &actor_pk.to_hex());
let unsigned = super::roster::build_invite_links_edition_unsigned(actor_pk, &community.id, my_locators, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign invite-links edition: {e}"))?;
let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
transport.publish_durable(&outer, &community.relays).await?;
if session.is_valid() {
crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
let mut agg: std::collections::BTreeSet<String> =
crate::db::community::get_community_invite_registry(&cid)?.into_iter().collect();
agg.extend(my_locators.iter().cloned());
crate::db::community::set_community_invite_registry(&cid, &agg.into_iter().collect::<Vec<_>>())?;
crate::db::community::upsert_invite_link_set(&cid, &actor_pk.to_hex(), my_locators)?;
}
Ok(())
}
pub async fn fetch_and_apply_invite_links<T: Transport + ?Sized>(
transport: &T,
community: &Community,
) -> Result<Vec<String>, String> {
fetch_and_apply_invite_links_inner(transport, community, None).await
}
async fn fetch_and_apply_invite_links_inner<T: Transport + ?Sized>(
transport: &T,
community: &Community,
prefolded: Option<super::roster::FoldedRoster>,
) -> Result<Vec<String>, String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
let folded = match prefolded {
Some(f) => f,
None => fetch_control_folded(transport, community).await?,
};
if !session.is_valid() {
return Err("account changed during invite-links fetch".to_string());
}
let owner = proven_owner_hex(community);
let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
let mut aggregate: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut per_creator: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
for set in &folded.invite_link_sets {
if !authorized.is_authorized(&set.creator.to_hex(), owner.as_deref(), super::roles::Permissions::CREATE_INVITE) {
continue;
}
let held = crate::db::community::get_edition_head(&cid, &set.head.entity_hex)?.map(|(v, _)| v).unwrap_or(0);
if set.head.version > held {
crate::db::community::set_edition_head(&cid, &set.head.entity_hex, set.head.version, &set.head.self_hash)?;
}
aggregate.extend(set.locators.iter().cloned());
per_creator.push(crate::db::community::InviteLinkSetRow {
creator_hex: set.creator.to_hex(),
locators: set.locators.clone(),
});
}
let aggregate: Vec<String> = aggregate.into_iter().collect();
crate::db::community::set_community_invite_registry(&cid, &aggregate)?;
crate::db::community::replace_invite_link_sets(&cid, &per_creator)?;
Ok(aggregate)
}
pub async fn fetch_and_apply_metadata<T: Transport + ?Sized>(
transport: &T,
community: &Community,
) -> Result<(), String> {
fetch_and_apply_metadata_inner(transport, community, None).await
}
async fn fetch_and_apply_metadata_inner<T: Transport + ?Sized>(
transport: &T,
community: &Community,
prefolded: Option<super::roster::FoldedRoster>,
) -> Result<(), String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
let folded = match prefolded {
Some(f) => f,
None => fetch_control_folded(transport, community).await?,
};
if !session.is_valid() {
return Err("account changed during metadata fetch".to_string());
}
let owner = proven_owner_hex(community);
let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
let manage = super::roles::Permissions::MANAGE_METADATA;
let manage_channels = super::roles::Permissions::MANAGE_CHANNELS;
let mut current = match crate::db::community::load_community(&community.id)? {
Some(c) => c,
None => return Ok(()),
};
let mut dirty = false;
let mut head_updates: Vec<(String, u64, [u8; 32], [u8; 32], bool)> = Vec::new();
let decide = |entity_hex: &str, head: &super::roster::EntityHead| -> Result<Option<bool>, String> {
let held = crate::db::community::get_edition_head(&cid, entity_hex)?;
let held_v = held.map(|(v, _)| v).unwrap_or(0);
if head.version > held_v {
return Ok(Some(false)); }
if head.version == held_v && held.map(|(_, h)| h) != Some(head.self_hash) {
let held_id = crate::db::community::get_edition_head_inner_id(&cid, entity_hex)?;
if held_id.is_none() || Some(head.inner_id) < held_id {
return Ok(Some(true)); }
}
Ok(None)
};
if let Some(c) = folded.root_candidates.iter()
.find(|c| authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), manage))
{
let head = &c.head;
if let Some(is_converge) = decide(&head.entity_hex, head)? {
let meta = &c.meta;
current.name = meta.name.clone();
current.description = meta.description.clone();
current.icon = meta.icon.clone();
current.banner = meta.banner.clone();
dirty = true;
head_updates.push((head.entity_hex.clone(), head.version, head.self_hash, head.inner_id, is_converge));
}
}
let mut resolved_channels: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
for cm in &folded.channel_candidates {
if resolved_channels.contains(&cm.channel_id) {
continue; }
if !authorized.is_authorized(&cm.author.to_hex(), owner.as_deref(), manage_channels) {
continue; }
resolved_channels.insert(cm.channel_id);
let Some(is_converge) = decide(&cm.head.entity_hex, &cm.head)? else { continue };
if let Some(ch) = current.channels.iter_mut().find(|c| c.id.0 == cm.channel_id) {
ch.name = cm.meta.name.clone();
dirty = true;
head_updates.push((cm.head.entity_hex.clone(), cm.head.version, cm.head.self_hash, cm.head.inner_id, is_converge));
}
}
if dirty && session.is_valid() {
crate::db::community::save_community(¤t)?;
for (entity_hex, version, self_hash, inner_id, is_converge) in &head_updates {
if *is_converge {
crate::db::community::converge_edition_head(&cid, entity_hex, *version, self_hash, inner_id)?;
} else {
crate::db::community::set_edition_head_with_id(&cid, entity_hex, *version, self_hash, inner_id)?;
}
}
}
Ok(())
}
pub fn is_public(community: &Community) -> Result<bool, String> {
Ok(!crate::db::community::get_community_invite_registry(&community.id.to_hex())?.is_empty())
}
async fn republish_my_invite_links<T: Transport + ?Sized>(
transport: &T,
community: &Community,
) -> Result<Vec<String>, String> {
let cid = community.id.to_hex();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let locators: Vec<String> = crate::db::community::list_public_invites(&cid)?
.iter()
.filter(|r| r.expires_at.map_or(true, |e| (e as u64) > now))
.map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
.collect();
publish_my_invite_links(transport, community, &locators).await?;
Ok(locators)
}
async fn observe_channel_activity<T: Transport + ?Sized>(
transport: &T,
community: &Community,
) -> Result<(), String> {
let session = SessionGuard::capture();
let my_pk = crate::state::my_public_key().ok_or("no local identity to observe channel activity")?;
for channel in &community.channels {
let events = super::send::fetch_channel_events(transport, community, channel)
.await
.unwrap_or_default();
if !session.is_valid() {
return Err("account changed during activity observation".to_string());
}
let outcomes = {
let mut st = crate::state::STATE.lock().await;
super::inbound::process_channel_batch(&mut st, &events, channel, &my_pk)
};
let ch_hex = channel.id.to_hex();
for o in &outcomes {
match o {
super::inbound::IncomingEvent::NewMessage(m)
| super::inbound::IncomingEvent::Updated { message: m, .. } => {
let _ = crate::db::events::save_message(&ch_hex, m).await;
}
super::inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label } => {
let et = if *joined {
crate::stored_event::SystemEventType::MemberJoined
} else {
crate::stored_event::SystemEventType::MemberLeft
};
let note = invited_by.as_ref().map(|by| match invited_label {
Some(l) if !l.is_empty() => format!("{by}|{l}"),
_ => by.clone(),
});
let _ = crate::db::events::save_system_event_at(event_id, &ch_hex, et, npub, note.as_deref(), *created_at, invited_by.as_deref(), invited_label.as_deref()).await;
}
super::inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at } => {
persist_webxdc_signal(&ch_hex, npub, topic_id, node_addr.as_deref(), event_id, *created_at).await;
}
_ => {}
}
}
}
Ok(())
}
pub async fn sync_before_admin_write<T: Transport + ?Sized>(
transport: &T,
community: &Community,
observe_activity: bool,
) -> Result<Community, String> {
if catch_up_server_root(transport, community).await?.removed {
return Err("you have been removed from this community".to_string());
}
let community = crate::db::community::load_community(&community.id)?
.ok_or("community gone during admin sync")?;
let cid = community.id.to_hex();
let responded = fetch_and_apply_control(transport, &community).await.map(|n| n > 0).unwrap_or(false);
let hold_local_heads = !crate::db::community::get_all_edition_heads_epoched(&cid)?.is_empty();
if hold_local_heads && !responded {
return Err("can't reach any relay to confirm this community's current state — administrative actions are blocked while offline (try again when connected)".to_string());
}
let community = crate::db::community::load_community(&community.id)?
.ok_or("community gone during admin sync")?;
if observe_activity {
let _ = observe_channel_activity(transport, &community).await;
}
crate::db::community::load_community(&community.id)?.ok_or("community gone during admin sync".to_string())
}
async fn run_read_cut<T: Transport + ?Sized>(
transport: &T,
community: &Community,
fresh: bool,
) -> Result<(), String> {
let cid = community.id.to_hex();
let session = SessionGuard::capture();
if fresh {
let base = crate::db::community::load_community(&community.id)?
.map(|c| c.server_root_epoch.0)
.unwrap_or(community.server_root_epoch.0);
crate::db::community::set_read_cut_target_epoch(&cid, base.saturating_add(1))?;
}
crate::db::community::set_read_cut_pending(&cid, true)?;
reseal_base_to_observed(transport, community).await?;
if session.is_valid() {
crate::db::community::set_read_cut_pending(&cid, false)?;
}
Ok(())
}
async fn reseal_base_to_observed<T: Transport + ?Sized>(
transport: &T,
community: &Community,
) -> Result<(), String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
let community = &sync_before_admin_write(transport, community, true).await?;
let participants: Vec<nostr_sdk::PublicKey> = crate::db::community::community_member_activity(&cid)?
.into_iter()
.filter_map(|(npub, _)| nostr_sdk::PublicKey::parse(&npub).ok())
.collect();
let target = crate::db::community::get_read_cut_target_epoch(&cid)?;
if community.server_root_epoch.0 < target {
rotate_server_root(transport, community, &participants).await?;
if !session.is_valid() {
return Err("account changed during re-founding".to_string());
}
}
let community = crate::db::community::load_community(&community.id)?
.ok_or("community gone after base rotation")?;
let cut_epoch = community.server_root_epoch.0;
let prior_root = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, cut_epoch.saturating_sub(1))?
.unwrap_or(*community.server_root_key.as_bytes()); for channel in &community.channels {
let ch_hex = channel.id.to_hex();
if crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex)? >= cut_epoch {
continue;
}
rotate_channel(transport, &community, &channel.id, &participants, &prior_root).await?;
if !session.is_valid() {
return Err("account changed during re-founding".to_string());
}
crate::db::community::mark_channel_rekeyed_at_server_epoch(&cid, &ch_hex, cut_epoch)?;
}
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
pub enum RekeyOutcome {
Applied { head_advanced: bool },
NotARecipient,
}
pub fn apply_channel_rekey(
community: &Community,
parsed: &super::rekey::ParsedRekey,
) -> Result<RekeyOutcome, String> {
let session = SessionGuard::capture();
let channel_id = match parsed.scope {
super::derive::RekeyScope::Channel(c) => c,
super::derive::RekeyScope::ServerRoot => {
return Err("server-root rotation uses apply_server_root_rekey, not the channel path".to_string())
}
};
if !community.channels.iter().any(|c| c.id == channel_id) {
return Err("rekey targets a channel not in this community".to_string());
}
let cid = community.id.to_hex();
let channel_hex = channel_id.to_hex();
let owner = proven_owner_hex(community);
let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
crate::log_warn!("rekey apply: roster read failed ({e}); authorizing owner only");
Default::default()
});
if !roster.is_authorized(
&parsed.rotator.to_hex(),
owner.as_deref(),
super::roles::Permissions::MANAGE_CHANNELS,
) {
return Err("rekey rotator lacks MANAGE_CHANNELS authority".to_string());
}
if let Some(prev_key) = crate::db::community::held_epoch_key(&cid, &channel_hex, parsed.prev_epoch.0)? {
if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_key) != parsed.prev_key_commitment {
crate::log_warn!(
"channel rekey to epoch {} cites a prior-epoch key I don't hold (I'm on a losing fork of epoch {}) — converging forward onto the authorized chain",
parsed.new_epoch.0, parsed.prev_epoch.0
);
}
}
let my_keys = crate::state::MY_SECRET_KEY
.to_keys()
.ok_or("no local identity to open the rekey blob")?;
let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
Some(b) => b,
None => return Ok(RekeyOutcome::NotARecipient),
};
let new_key =
super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
if !session.is_valid() {
return Err("session changed during rekey apply".to_string());
}
let head_advanced =
crate::db::community::advance_channel_epoch(&cid, &channel_hex, parsed.new_epoch.0, &new_key)?;
Ok(RekeyOutcome::Applied { head_advanced })
}
fn mint_or_reuse_rotation_key(cid: &str, scope_id: &str, epoch: u64) -> Result<zeroize::Zeroizing<[u8; 32]>, String> {
if let Some(k) = crate::db::community::held_epoch_key(cid, scope_id, epoch)? {
return Ok(zeroize::Zeroizing::new(k));
}
let k = zeroize::Zeroizing::new(super::random_32());
crate::db::community::store_epoch_key(cid, scope_id, epoch, &k)?;
Ok(k)
}
async fn publish_rekey_chunked<T, F>(
transport: &T,
relays: &[String],
blobs: &[super::rekey::RekeyBlob],
build: F,
) -> Result<(), String>
where
T: Transport + ?Sized,
F: Fn(&[super::rekey::RekeyBlob]) -> Result<Event, String>,
{
if blobs.is_empty() {
return Err("rekey has no recipients".to_string());
}
for chunk in blobs.chunks(super::rekey::MAX_REKEY_BLOBS) {
let event = build(chunk)?;
transport.publish_durable(&event, relays).await?;
}
Ok(())
}
pub async fn rotate_channel<T: Transport + ?Sized>(
transport: &T,
community: &Community,
channel_id: &super::ChannelId,
recipients: &[nostr_sdk::PublicKey],
envelope_root: &[u8; 32],
) -> Result<u64, String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("a key rotation requires a local key (bunker/NIP-46 accounts can't rekey)")?;
let owner = proven_owner_hex(community);
let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
return Err("not authorized to rotate this channel (no MANAGE_CHANNELS)".to_string());
}
let channel = community
.channels
.iter()
.find(|c| &c.id == channel_id)
.ok_or("channel not found in community")?;
let prev_epoch = channel.epoch;
let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, channel.key.as_bytes());
let new_key = mint_or_reuse_rotation_key(&cid, &channel_id.to_hex(), new_epoch.0)?;
let mut seen = std::collections::HashSet::new();
let mut blobs = Vec::new();
for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
if !seen.insert(pk.to_hex()) {
continue;
}
blobs.push(super::rekey::build_rekey_blob(
my_keys.secret_key(), pk, super::derive::RekeyScope::Channel(*channel_id), new_epoch, &new_key,
)?);
}
publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
super::rekey::build_channel_rekey_event(
&Keys::generate(), &my_keys, envelope_root, channel_id,
new_epoch, prev_epoch, &prev_commit, chunk,
)
})
.await?;
if !session.is_valid() {
return Err("session changed during channel rotation".to_string());
}
crate::db::community::advance_channel_epoch(&cid, &channel_id.to_hex(), new_epoch.0, &new_key)?;
Ok(new_epoch.0)
}
fn emit_rekey_progress(label: &str, pct: u8) {
crate::emit_event("community_rekey_progress", &serde_json::json!({ "label": label, "pct": pct }));
}
pub(crate) async fn rotate_server_root<T: Transport + ?Sized>(
transport: &T,
community: &Community,
recipients: &[nostr_sdk::PublicKey],
) -> Result<u64, String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
if crate::db::community::get_community_dissolved(&cid)? {
return Err("community is dissolved; it cannot be re-founded".to_string());
}
let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("a base rotation (privatize / private-ban read-cut) requires a local key (bunker/NIP-46 accounts can't rekey)")?;
let owner = proven_owner_hex(community);
let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::BAN) {
return Err("not authorized to rotate the server root (no BAN)".to_string());
}
let fresh = crate::db::community::load_community(&community.id)?
.ok_or("community gone before base rotation")?;
let community = &fresh;
let prev_epoch = community.server_root_epoch;
let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("server-root epoch overflow")?);
let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, community.server_root_key.as_bytes());
let new_root = mint_or_reuse_rotation_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
emit_rekey_progress("Rerolling community keys...", 5);
let sealed = prepare_reanchor_control_plane(transport, community, &new_root, new_epoch).await?;
if !session.is_valid() {
return Err("session changed during re-founding acquire".to_string());
}
let total_recipients = (recipients.len() + 1).max(1); let mut seen = std::collections::HashSet::new();
let mut blobs = Vec::new();
for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
if !seen.insert(pk.to_hex()) {
continue;
}
blobs.push(super::rekey::build_rekey_blob(
my_keys.secret_key(), pk, super::derive::RekeyScope::ServerRoot, new_epoch, &new_root,
)?);
emit_rekey_progress(
&format!("Preparing keys for members ({}/{})...", blobs.len(), total_recipients),
(5 + 35 * blobs.len() / total_recipients) as u8,
);
}
emit_rekey_progress("Sending keys to members...", 42);
publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
super::rekey::build_server_root_rekey_event(
&Keys::generate(), &my_keys, community.server_root_key.as_bytes(), &community.id,
new_epoch, prev_epoch, &prev_commit, chunk,
)
})
.await?;
let snapshot = publish_reanchor_snapshot(transport, &community.relays, sealed).await?;
if snapshot.iter().any(|e| !e.published) {
return Err(
"re-founding aborted: a snapshot edition did not land (rate-limited / unreachable relay?); base head NOT advanced".to_string()
);
}
if !session.is_valid() {
return Err("session changed during server-root rotation".to_string());
}
emit_rekey_progress("Finalizing...", 98);
crate::db::community::advance_server_root_epoch(&cid, new_epoch.0, &new_root)?;
for e in &snapshot {
crate::db::community::set_edition_head_with_id(&cid, &e.entity_hex, e.version, &e.self_hash, &e.inner_id)?;
}
Ok(new_epoch.0)
}
pub(crate) struct SnapshotEntry {
pub entity_hex: String,
pub version: u64,
pub self_hash: [u8; 32],
pub inner_id: [u8; 32],
pub published: bool,
}
pub(crate) async fn prepare_reanchor_control_plane<T: Transport + ?Sized>(
transport: &T,
community: &Community,
new_root: &[u8; 32],
new_epoch: super::Epoch,
) -> Result<Vec<(Event, SnapshotEntry)>, String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
let z = super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
let outers = transport.fetch(&query, &community.relays).await?;
if !session.is_valid() {
return Err("session changed during re-founding fetch".to_string());
}
let mut by_hash: std::collections::HashMap<[u8; 32], (Event, [u8; 32])> = std::collections::HashMap::new();
for outer in &outers {
if let Ok(inner) = super::roster::open_control_edition(outer, &community.server_root_key) {
if let Ok(parsed) = super::edition::parse_edition_inner(&inner) {
by_hash.insert(parsed.self_hash, (inner, parsed.inner_id));
}
}
}
let new_root_key = super::ServerRootKey(*new_root);
let mut sealed: Vec<(Event, SnapshotEntry)> = Vec::new();
for (entity_hex, (epoch, version, self_hash)) in crate::db::community::get_all_edition_heads_epoched(&cid)? {
if epoch != community.server_root_epoch.0 {
continue; }
let (inner, inner_id) = by_hash.get(&self_hash).ok_or_else(|| {
format!("re-founding aborted: head edition for entity {entity_hex} (v{version}) not fetchable — aborting so no member is stranded")
})?;
let outer = super::roster::seal_control_edition(&Keys::generate(), inner, &new_root_key, &community.id, new_epoch)?;
sealed.push((outer, SnapshotEntry { entity_hex, version, self_hash, inner_id: *inner_id, published: false }));
}
Ok(sealed)
}
pub(crate) async fn publish_reanchor_snapshot<T: Transport + ?Sized>(
transport: &T,
relays: &[String],
sealed: Vec<(Event, SnapshotEntry)>,
) -> Result<Vec<SnapshotEntry>, String> {
use futures_util::stream::StreamExt;
let total = sealed.len().max(1);
let done = std::sync::atomic::AtomicUsize::new(0);
let done_ref = &done;
emit_rekey_progress(&format!("Re-founding community (0/{total})..."), 50);
let out: Vec<SnapshotEntry> = futures_util::stream::iter(sealed.into_iter().map(|(ev, mut entry)| async move {
entry.published = transport.publish_durable(&ev, relays).await.is_ok();
let n = done_ref.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
emit_rekey_progress(&format!("Re-founding community ({n}/{total})..."), (50 + 45 * n / total) as u8);
entry
}))
.buffer_unordered(4)
.collect()
.await;
Ok(out)
}
#[cfg(test)]
pub(crate) async fn reanchor_control_plane<T: Transport + ?Sized>(
transport: &T,
community: &Community,
new_root: &[u8; 32],
new_epoch: super::Epoch,
) -> Result<Vec<SnapshotEntry>, String> {
let sealed = prepare_reanchor_control_plane(transport, community, new_root, new_epoch).await?;
publish_reanchor_snapshot(transport, &community.relays, sealed).await
}
pub fn apply_server_root_rekey(
community: &Community,
parsed: &super::rekey::ParsedRekey,
) -> Result<RekeyOutcome, String> {
let session = SessionGuard::capture();
if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
return Err("not a server-root rekey (channel rekeys use apply_channel_rekey)".to_string());
}
let cid = community.id.to_hex();
if crate::db::community::get_community_dissolved(&cid)? {
return Err("community is dissolved; base epoch cannot advance".to_string());
}
let owner = proven_owner_hex(community);
let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
crate::log_warn!("base rekey apply: roster read failed ({e}); authorizing owner only");
Default::default()
});
if !rotator_is_authorized(&cid, &roster, owner.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
return Err("base rekey rotator lacks server-wide rotation authority (BAN)".to_string());
}
if let Some(prev_root) =
crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, parsed.prev_epoch.0)?
{
if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_root) != parsed.prev_key_commitment {
crate::log_warn!(
"base rekey to epoch {} cites a prior-root I don't hold (I'm on a losing fork of epoch {}) — converging forward onto the authorized chain",
parsed.new_epoch.0, parsed.prev_epoch.0
);
}
}
let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local identity to open the base rekey blob")?;
let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
Some(b) => b,
None => return Ok(RekeyOutcome::NotARecipient),
};
let new_root =
super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
if !session.is_valid() {
return Err("session changed during base rekey apply".to_string());
}
let head_advanced = crate::db::community::advance_server_root_epoch(&cid, parsed.new_epoch.0, &new_root)?;
Ok(RekeyOutcome::Applied { head_advanced })
}
const REKEY_CATCHUP_WINDOW: u64 = 64;
const MAX_REKEY_CATCHUP_ROUNDS: usize = 64;
async fn heal_channel_fork_epochs<T: Transport + ?Sized>(
transport: &T,
community: &Community,
channel_id: &super::ChannelId,
cid: &str,
channel_hex: &str,
epochs: &std::collections::BTreeSet<u64>,
server_roots: &[[u8; 32]],
session: &SessionGuard,
) -> Result<(), String> {
if epochs.is_empty() {
return Ok(());
}
let owner_hex = proven_owner_hex(community);
let roster = crate::db::community::get_community_roles(cid).unwrap_or_default();
let mut winner: std::collections::BTreeMap<u64, [u8; 32]> = std::collections::BTreeMap::new();
for sr in server_roots {
let z_tags: Vec<String> = epochs
.iter()
.map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
.collect();
let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
for ev in transport.fetch(&q, &community.relays).await.unwrap_or_default() {
let Ok(p) = super::rekey::open_rekey_event(&ev, sr) else { continue };
if !matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) || !epochs.contains(&p.new_epoch.0) {
continue;
}
if !rotator_is_authorized(cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::MANAGE_CHANNELS) {
continue;
}
let Some(key) = peek_my_channel_key(&p) else { continue }; winner.entry(p.new_epoch.0).and_modify(|best| { if key < *best { *best = key; } }).or_insert(key);
}
}
for (epoch, win_key) in winner {
if !session.is_valid() {
return Err("session changed during channel convergence".to_string());
}
if let Ok(Some(cur)) = crate::db::community::held_epoch_key(cid, channel_hex, epoch) {
if win_key < cur {
match crate::db::community::converge_channel_epoch(cid, channel_hex, epoch, &win_key) {
Ok(false) => crate::log_trace!("channel heal: converge of epoch {epoch} did not apply (head moved)"),
Err(e) => crate::log_trace!("channel heal: converge of epoch {epoch} errored: {e}"),
Ok(true) => {}
}
}
}
}
Ok(())
}
pub async fn catch_up_channel_rekeys<T: Transport + ?Sized>(
transport: &T,
community: &Community,
channel_id: &super::ChannelId,
) -> Result<u64, String> {
let session = SessionGuard::capture();
let server_root = community.server_root_key.as_bytes();
let cid = community.id.to_hex();
let channel_hex = channel_id.to_hex();
let mut server_roots: Vec<[u8; 32]> = crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX)
.unwrap_or_default()
.into_iter()
.map(|(_, k)| k)
.collect();
if !server_roots.iter().any(|r| r == server_root) {
server_roots.push(*server_root); }
let mut head = community
.channels
.iter()
.find(|c| &c.id == channel_id)
.ok_or("channel not found in community")?
.epoch
.0;
let mut forked_epochs: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
for _round in 0..MAX_REKEY_CATCHUP_ROUNDS {
let window_top = head.saturating_add(REKEY_CATCHUP_WINDOW);
let mut parsed: Vec<super::rekey::ParsedRekey> = Vec::new();
for sr in &server_roots {
let z_tags: Vec<String> = (head.saturating_add(1)..=window_top)
.map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(e)).to_hex())
.collect();
let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
parsed.push(p);
}
}
}
}
if parsed.is_empty() {
break; }
parsed.sort_by_key(|p| p.new_epoch.0);
let max_found = parsed.last().map(|p| p.new_epoch.0).unwrap_or(head);
let head_before = head;
let mut removed = false;
let mut by_epoch: std::collections::BTreeMap<u64, Vec<&super::rekey::ParsedRekey>> = std::collections::BTreeMap::new();
for p in &parsed {
by_epoch.entry(p.new_epoch.0).or_default().push(p);
}
for (e, chunks) in by_epoch {
if !session.is_valid() {
return Err("session changed during rekey catch-up".to_string());
}
let mut applied = false;
let mut saw_not_recipient = false;
for p in &chunks {
match apply_channel_rekey(community, p) {
Ok(RekeyOutcome::Applied { .. }) => {
applied = true;
break;
}
Ok(RekeyOutcome::NotARecipient) => saw_not_recipient = true,
Err(err) => crate::log_warn!("rekey catch-up: skipping epoch {e} chunk: {err}"),
}
}
if applied {
if let Some(p) = chunks.first() {
let pe = p.prev_epoch.0;
if let Ok(Some(prev_key)) = crate::db::community::held_epoch_key(&cid, &channel_hex, pe) {
if super::rekey::epoch_key_commitment(p.prev_epoch, &prev_key) != p.prev_key_commitment {
forked_epochs.insert(pe);
}
}
}
if e > head + 1 {
crate::log_warn!(
"rekey catch-up: channel epochs {}..={} not recovered (key gap; history unreadable until re-fetched)",
head + 1, e - 1
);
}
head = head.max(e);
} else if saw_not_recipient {
removed = true;
break;
}
}
if removed || head == head_before || max_found < window_top {
break;
}
}
let held: std::collections::HashSet<u64> = crate::db::community::held_epoch_keys(&cid, &channel_hex)
.unwrap_or_default()
.into_iter()
.map(|(e, _)| e.0)
.collect();
let missing: Vec<u64> = (0..head).filter(|e| !held.contains(e)).collect();
if !missing.is_empty() {
for sr in &server_roots {
if !session.is_valid() {
return Err("session changed during rekey gap-fill".to_string());
}
let z_tags: Vec<String> = missing
.iter()
.map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
.collect();
let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
let _ = apply_channel_rekey(community, &p); }
}
}
}
}
if head > 0 && session.is_valid() {
let lo = head.saturating_sub(REKEY_CATCHUP_WINDOW).max(1);
let mut epochs: std::collections::BTreeSet<u64> = (lo..=head).collect();
epochs.append(&mut forked_epochs);
let _ = heal_channel_fork_epochs(transport, community, channel_id, &cid, &channel_hex, &epochs, &server_roots, &session).await;
}
Ok(head)
}
const MAX_BASE_CATCHUP_STEPS: usize = 256;
fn peek_my_server_root(parsed: &super::rekey::ParsedRekey) -> Result<Option<[u8; 32]>, String> {
if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
return Ok(None);
}
let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local key to open a base rekey blob")?;
let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
Some(b) => b,
None => return Ok(None),
};
super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).map(Some)
}
fn peek_my_channel_key(parsed: &super::rekey::ParsedRekey) -> Option<[u8; 32]> {
let my_keys = crate::state::MY_SECRET_KEY.to_keys()?;
let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator).ok()?;
let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
let mine = parsed.blobs.iter().find(|b| b.locator == my_locator)?;
super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).ok()
}
pub async fn catch_up_server_root<T: Transport + ?Sized>(
transport: &T,
community: &Community,
) -> Result<BaseCatchup, String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
let mut head = community.server_root_epoch.0;
let mut removed = false;
let mut current_root: [u8; 32] = *community.server_root_key.as_bytes();
for _step in 0..MAX_BASE_CATCHUP_STEPS {
let next = match head.checked_add(1) {
Some(n) => n,
None => break,
};
let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(current_root), &community.id, super::Epoch(next)).to_hex();
let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
let events = transport.fetch(&query, &community.relays).await?;
if events.is_empty() {
break; }
let chunks: Vec<super::rekey::ParsedRekey> = events
.iter()
.filter_map(|ev| super::rekey::open_rekey_event(ev, ¤t_root).ok())
.filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == next)
.collect();
if chunks.is_empty() {
break; }
if !session.is_valid() {
return Err("session changed during base rekey catch-up".to_string());
}
let owner_hex = proven_owner_hex(community);
let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
let mut candidates: Vec<(&super::rekey::ParsedRekey, [u8; 32])> = Vec::new();
for parsed in &chunks {
if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
continue;
}
match peek_my_server_root(parsed) {
Ok(Some(root)) => candidates.push((parsed, root)),
Ok(None) => {}
Err(err) => crate::log_warn!("base rekey catch-up: epoch {next} peek: {err}"),
}
}
let applied = match candidates.into_iter().min_by(|a, b| a.1.cmp(&b.1)) {
Some((parsed, _)) => match apply_server_root_rekey(community, parsed) {
Ok(RekeyOutcome::Applied { .. }) => true,
Ok(RekeyOutcome::NotARecipient) => false, Err(err) => { crate::log_warn!("base rekey catch-up: epoch {next} apply: {err}"); false }
},
None => {
let owner = proven_owner_hex(community);
let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
if chunks.iter().any(|p| rotator_is_authorized(&cid, &roster, owner.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)) {
removed = true;
}
false }
};
if !applied {
break;
}
match crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, next)? {
Some(root) => {
current_root = root;
head = next;
}
None => {
crate::log_warn!("base rekey catch-up: epoch {next} applied but its root is not archived; halting walk");
break;
}
}
}
if head > 0 && !removed {
if let Ok(Some(prior_root)) = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, head - 1) {
let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(prior_root), &community.id, super::Epoch(head)).to_hex();
let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
let events = transport.fetch(&query, &community.relays).await.unwrap_or_default();
let chunks: Vec<super::rekey::ParsedRekey> = events
.iter()
.filter_map(|ev| super::rekey::open_rekey_event(ev, &prior_root).ok())
.filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == head)
.collect();
let owner_hex = proven_owner_hex(community);
let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
let mut best: Option<(&super::rekey::ParsedRekey, [u8; 32])> = None;
for p in &chunks {
if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN) {
continue;
}
if let Ok(Some(root)) = peek_my_server_root(p) {
if best.as_ref().map_or(true, |(_, br)| root < *br) {
best = Some((p, root));
}
}
}
let current_deauthorized = chunks.iter().any(|p| {
matches!(peek_my_server_root(p), Ok(Some(r)) if r == current_root)
&& !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)
});
if let Some((winner, win_root)) = best {
let adopt = if current_deauthorized {
win_root != current_root
} else {
win_root < current_root
};
if adopt {
if !session.is_valid() {
return Err("session changed during base convergence".to_string());
}
if apply_server_root_rekey(community, winner).is_ok() {
match crate::db::community::converge_server_root_epoch(&cid, head, &win_root) {
Ok(false) => crate::log_trace!("base heal: converge of epoch {head} did not apply (head moved)"),
Err(e) => crate::log_trace!("base heal: converge of epoch {head} errored: {e}"),
Ok(true) => {}
}
current_root = win_root;
if let Ok(Some(fresh)) = crate::db::community::load_community(&community.id) {
let _ = fetch_and_apply_control(transport, &fresh).await;
}
}
}
}
}
}
let _ = current_root; Ok(BaseCatchup { epoch: head, removed })
}
#[derive(Debug, Clone, Copy)]
pub struct BaseCatchup {
pub epoch: u64,
pub removed: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::community::send::fetch_channel_messages;
use crate::community::transport::{memory::MemoryRelay, Query, Transport};
use nostr_sdk::prelude::{EventBuilder, Kind};
struct FailingRelay;
#[async_trait::async_trait]
impl Transport for FailingRelay {
async fn publish(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
Err("relay unreachable".to_string())
}
async fn publish_durable(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
Err("relay unreachable".to_string())
}
async fn fetch(&self, _query: &Query, _relays: &[String]) -> Result<Vec<Event>, String> {
Ok(Vec::new())
}
}
struct RekeyFailingRelay {
inner: MemoryRelay,
fail_rekey: std::sync::atomic::AtomicBool,
}
impl RekeyFailingRelay {
fn new() -> Self {
Self { inner: MemoryRelay::new(), fail_rekey: std::sync::atomic::AtomicBool::new(true) }
}
fn allow_rekey(&self) {
self.fail_rekey.store(false, std::sync::atomic::Ordering::Relaxed);
}
fn blocks(&self, event: &Event) -> bool {
self.fail_rekey.load(std::sync::atomic::Ordering::Relaxed)
&& event.kind.as_u16() == crate::stored_event::event_kind::COMMUNITY_REKEY
}
}
#[async_trait::async_trait]
impl Transport for RekeyFailingRelay {
async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
if self.blocks(event) { return Err("rekey relay down".to_string()); }
self.inner.publish(event, relays).await
}
async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
if self.blocks(event) { return Err("rekey relay down".to_string()); }
self.inner.publish_durable(event, relays).await
}
async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
self.inner.fetch(query, relays).await
}
}
static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(5000);
fn make_test_npub(n: u32) -> String {
const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
let mut payload = vec![b'q'; 58];
let mut x = n as u64;
let mut i = 58;
while x > 0 && i > 0 {
i -= 1;
payload[i] = BECH32[(x as usize) % 32];
x /= 32;
}
format!("npub1{}", std::str::from_utf8(&payload).unwrap())
}
fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
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();
let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let account = make_test_npub(n);
std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
crate::db::set_app_data_dir(tmp.path().to_path_buf());
crate::db::set_current_account(account.clone()).unwrap();
crate::db::init_database(&account).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)
}
#[test]
fn community_cap_rejects_a_new_membership_at_the_limit() {
let (_tmp, _guard) = init_test_db();
let mk = |i: usize| {
let id = format!("{:064x}", i);
crate::community::list::CommunityListEntry {
community_id: id.clone(),
seed: crate::community::invite::CommunityInvite {
community_id: id,
name: String::new(),
server_root_key: String::new(),
server_root_epoch: 0,
relays: vec![],
channels: vec![],
owner_attestation: None,
icon: None,
},
current: None,
added_at: 0,
}
};
let mut list = crate::community::list::CommunityList::default();
for i in 0..(MAX_COMMUNITIES - 1) {
list.entries.push(mk(i));
}
crate::db::settings::set_sql_setting("community_list_json".to_string(), list.to_json()).unwrap();
assert!(enforce_community_cap().is_ok(), "under the cap a new join is allowed");
list.entries.push(mk(MAX_COMMUNITIES - 1)); crate::db::settings::set_sql_setting("community_list_json".to_string(), list.to_json()).unwrap();
assert!(enforce_community_cap().is_err(), "at the cap a new join is rejected");
}
fn saved_community_owned_by(owner: &Keys) -> Community {
use nostr_sdk::JsonUtil;
let mut community = Community::create("HQ", "general", vec!["r".into()]);
let cid = community.id.to_hex();
community.owner_attestation = Some(
crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
.sign_with_keys(owner)
.unwrap()
.as_json(),
);
crate::db::community::save_community(&community).unwrap();
community
}
fn attested_community(name: &str, channel: &str, relays: Vec<String>) -> Community {
use nostr_sdk::JsonUtil;
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let mut community = Community::create(name, channel, relays);
community.owner_attestation = Some(
crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &community.id.to_hex())
.sign_with_keys(&owner).unwrap().as_json(),
);
community
}
fn become_local(me: &Keys) {
crate::state::MY_SECRET_KEY.store_from_keys(me, &[]);
crate::state::set_my_public_key(me.public_key());
}
fn owner_channel_rekey(
owner: &Keys,
community: &Community,
recipient_pk: &nostr_sdk::PublicKey,
new_epoch: u64,
new_key: &[u8; 32],
) -> super::super::rekey::ParsedRekey {
let chan = &community.channels[0];
let scope = super::super::derive::RekeyScope::Channel(chan.id);
let blob = super::super::rekey::build_rekey_blob(
owner.secret_key(), recipient_pk, scope, crate::community::Epoch(new_epoch), new_key,
)
.unwrap();
let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes());
let outer = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
crate::community::Epoch(new_epoch), crate::community::Epoch(0), &commit, &[blob],
)
.unwrap();
super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
}
#[tokio::test]
async fn outer_event_dedup_skips_an_already_persisted_wire_event() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let channel = community.channels[0].clone();
let chan_hex = channel.id.to_hex();
let author = Keys::generate();
let outer = crate::community::envelope::seal_message(
&author, &channel.key, &channel.id, channel.epoch, "gm", 1000,
).unwrap();
let outer_hex = outer.id.to_hex();
let mut state = crate::state::ChatState::new();
let msg = match crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key()) {
Some(crate::community::inbound::IncomingEvent::NewMessage(m)) => m,
_ => panic!("expected NewMessage from a fresh wire event"),
};
assert_eq!(msg.wrapper_event_id.as_deref(), Some(outer_hex.as_str()),
"the inner must carry its outer wire id as wrapper_event_id");
crate::db::events::save_message(&chan_hex, &msg).await.unwrap();
let mut state2 = crate::state::ChatState::new();
let second = crate::community::inbound::process_incoming(&mut state2, &outer, &channel, &me.public_key());
assert!(second.is_none(), "an already-processed wire event must dedup before decryption");
}
#[tokio::test]
async fn ledger_is_shared_but_negentropy_stays_nip17_only() {
let (_tmp, _guard) = init_test_db();
let dm = [0xA1u8; 32];
let concord = [0xC0u8; 32];
crate::db::wrappers::save_processed_wrapper(&dm, 100, crate::db::wrappers::TRANSPORT_NIP17).unwrap();
crate::db::wrappers::save_processed_wrapper(&concord, 200, crate::db::wrappers::TRANSPORT_CONCORD).unwrap();
assert!(crate::db::wrappers::processed_wrapper_exists(&dm));
assert!(crate::db::wrappers::processed_wrapper_exists(&concord));
let items = crate::db::wrappers::load_negentropy_items().unwrap();
assert_eq!(items.len(), 1, "negentropy must exclude concord wrappers");
assert_eq!(items[0].0.to_bytes(), dm);
}
#[tokio::test]
async fn non_message_subkind_dedups_via_the_shared_ledger() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let channel = community.channels[0].clone();
let author = Keys::generate();
let inner = super::super::envelope::build_inner_typed(
author.public_key(), &channel.id, channel.epoch,
crate::stored_event::event_kind::COMMUNITY_PRESENCE, "join", 5, None, &[],
).sign_with_keys(&author).unwrap();
let outer = super::super::envelope::seal_with_signed_inner(
&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch,
).unwrap();
let mut state = crate::state::ChatState::new();
let first = crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key());
assert!(matches!(first, Some(crate::community::inbound::IncomingEvent::Presence { .. })),
"expected a Presence outcome");
assert!(crate::db::wrappers::processed_wrapper_exists(&outer.id.to_bytes()),
"a non-message sub-kind must record its outer id in the shared ledger");
let second = crate::community::inbound::process_incoming(&mut crate::state::ChatState::new(), &outer, &channel, &me.public_key());
assert!(second.is_none(), "a re-fetched presence must dedup via the shared ledger");
}
#[test]
fn apply_channel_rekey_recovers_and_advances_head() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate(); let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let chan_hex = community.channels[0].id.to_hex();
let new_key = [0xCDu8; 32];
let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &new_key);
let outcome = apply_channel_rekey(&community, &parsed).unwrap();
assert_eq!(outcome, RekeyOutcome::Applied { head_advanced: true });
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(new_key));
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
assert_eq!(reloaded.channels[0].key.as_bytes(), &new_key);
assert!(crate::db::community::held_epoch_key(&cid, &chan_hex, 0).unwrap().is_some());
}
#[test]
fn apply_channel_rekey_accepts_matching_continuity() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
assert_eq!(
apply_channel_rekey(&community, &parsed).unwrap(),
RekeyOutcome::Applied { head_advanced: true },
"a rekey whose prior-key commitment matches the held genesis key applies"
);
}
#[test]
fn advance_channel_epoch_archives_when_no_head_row() {
let (_tmp, _guard) = init_test_db();
let cid = "f".repeat(64);
let orphan_channel = "a".repeat(64);
let advanced = crate::db::community::advance_channel_epoch(&cid, &orphan_channel, 2, &[0x77u8; 32]).unwrap();
assert!(!advanced, "no head row → head not advanced");
assert_eq!(crate::db::community::held_epoch_key(&cid, &orphan_channel, 2).unwrap(), Some([0x77u8; 32]), "key still archived");
}
#[tokio::test]
async fn rotate_channel_publishes_recoverable_rekey_and_advances_own_head() {
use crate::community::derive::{recipient_pseudonym, rekey_pseudonym};
use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
become_local(&owner); let community = saved_community_owned_by(&owner);
let channel_id = community.channels[0].id;
let member = Keys::generate(); let relay = MemoryRelay::new();
let new_epoch = rotate_channel(&relay, &community, &channel_id, &[member.public_key()], community.server_root_key.as_bytes())
.await
.expect("rotate");
assert_eq!(new_epoch, 1);
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
let addr = rekey_pseudonym(
&crate::community::ServerRootKey(*community.server_root_key.as_bytes()),
&channel_id, crate::community::Epoch(1),
)
.to_hex();
let found = relay
.fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
.await
.unwrap();
assert_eq!(found.len(), 1, "rekey addressable by its server-root pseudonym");
let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
assert_eq!(parsed.rotator, owner.public_key());
assert_eq!(parsed.new_epoch, crate::community::Epoch(1));
assert_eq!(parsed.prev_epoch, crate::community::Epoch(0));
assert_eq!(parsed.blobs.len(), 2, "the member + me (multi-device) each get a blob");
let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
assert_eq!(reloaded.channels[0].key.as_bytes(), &recovered, "member's recovered key == my advanced head key");
}
#[tokio::test]
async fn rotate_channel_failed_publish_leaves_head_unadvanced() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
become_local(&owner);
let community = saved_community_owned_by(&owner);
let member = Keys::generate();
let err = rotate_channel(&FailingRelay, &community, &community.channels[0].id, &[member.public_key()], community.server_root_key.as_bytes()).await;
assert!(err.is_err(), "a failed publish must propagate, not silently advance");
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0), "head stays put on publish failure");
}
fn build_rekey_chain(
owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::PublicKey, n: u64,
) -> (Vec<Event>, Vec<[u8; 32]>) {
let chan = &community.channels[0];
let scope = super::super::derive::RekeyScope::Channel(chan.id);
let mut prev_key = *chan.key.as_bytes();
let mut events = Vec::new();
let mut keys = Vec::new();
for e in 1..=n {
let new_key = [e as u8; 32];
let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient_pk, scope, crate::community::Epoch(e), &new_key).unwrap();
let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prev_key);
let ev = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
).unwrap();
events.push(ev);
keys.push(new_key);
prev_key = new_key;
}
(events, keys)
}
#[tokio::test]
async fn catch_up_steps_over_a_missing_epoch() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let channel_id = community.channels[0].id;
let cid = community.id.to_hex();
let chan_hex = channel_id.to_hex();
let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
let relay = MemoryRelay::new();
relay.inject(&events[0], &community.relays); relay.inject(&events[2], &community.relays); let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
assert_eq!(reached, 3, "head reaches the latest present epoch, stepping over the gap");
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(keys[0]));
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), None, "missing epoch is a hole");
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(keys[2]));
}
#[tokio::test]
async fn catch_up_recovers_a_rekey_under_a_prior_server_root() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let root0_community = saved_community_owned_by(&owner);
let cid = root0_community.id.to_hex();
let channel_id = root0_community.channels[0].id;
let chan_hex = channel_id.to_hex();
let scope = super::super::derive::RekeyScope::Channel(channel_id);
let genesis_key = *root0_community.channels[0].key.as_bytes();
let root1 = [0x99u8; 32];
crate::db::community::advance_server_root_epoch(&cid, 1, &root1).unwrap();
let community = crate::db::community::load_community(&root0_community.id).unwrap().unwrap();
assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
let (k1, k2) = ([0x11u8; 32], [0x22u8; 32]);
let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
let ev1 = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, root0_community.server_root_key.as_bytes(), &channel_id,
crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
let blob2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &k2).unwrap();
let commit1 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1);
let ev2 = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, &root1, &channel_id,
crate::community::Epoch(2), crate::community::Epoch(1), &commit1, &[blob2]).unwrap();
let relay = MemoryRelay::new();
relay.inject(&ev1, &community.relays);
relay.inject(&ev2, &community.relays);
let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
assert_eq!(reached, 2, "reached the latest channel epoch across the server-root rotation");
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
"epoch-1 key recovered from a rekey under the PRIOR server root");
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(k2));
}
#[tokio::test]
async fn catch_up_backfills_a_sub_head_gap() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let channel_id = community.channels[0].id;
let chan_hex = channel_id.to_hex();
let scope = super::super::derive::RekeyScope::Channel(channel_id);
let genesis_key = *community.channels[0].key.as_bytes();
let k2 = [0x22u8; 32];
crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &k2).unwrap();
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), None, "epoch 1 starts as a hole");
let k1 = [0x11u8; 32];
let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
let ev1 = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
let relay = MemoryRelay::new();
relay.inject(&ev1, &community.relays);
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
assert_eq!(reached, 2, "head unchanged (gap-fill never regresses it)");
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
"the sub-head hole was backfilled");
}
#[tokio::test]
async fn catch_up_walks_a_chain_of_rotations_to_the_latest() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me); let community = saved_community_owned_by(&owner);
let channel_id = community.channels[0].id;
let cid = community.id.to_hex();
let chan_hex = channel_id.to_hex();
let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
let relay = MemoryRelay::new();
for ev in events.iter().rev() {
relay.inject(ev, &community.relays);
}
let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
assert_eq!(reached, 3, "caught up to the latest epoch");
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(3));
assert_eq!(reloaded.channels[0].key.as_bytes(), &keys[2]);
for (i, k) in keys.iter().enumerate() {
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, (i + 1) as u64).unwrap(), Some(*k));
}
}
#[tokio::test]
async fn catch_up_slides_across_the_window_boundary() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let channel_id = community.channels[0].id;
let cid = community.id.to_hex();
let chan_hex = channel_id.to_hex();
let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 70);
let relay = MemoryRelay::new();
for ev in &events {
relay.inject(ev, &community.relays);
}
let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
assert_eq!(reached, 70, "slid past the 64-epoch window boundary to the latest");
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 70).unwrap(), Some(keys[69]));
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 64).unwrap(), Some(keys[63]), "window-1 keys retained too");
}
fn build_base_rekey_chain(
owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::PublicKey, n: u64,
) -> (Vec<Event>, Vec<[u8; 32]>) {
let mut prior_root = *community.server_root_key.as_bytes();
let mut events = Vec::new();
let mut roots = Vec::new();
for e in 1..=n {
let new_root = [(e % 256) as u8; 32];
let blob = super::super::rekey::build_rekey_blob(
owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(e), &new_root,
)
.unwrap();
let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prior_root);
events.push(super::super::rekey::build_server_root_rekey_event(
&Keys::generate(), owner, &prior_root, &community.id,
crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
).unwrap());
roots.push(new_root);
prior_root = new_root;
}
(events, roots)
}
#[tokio::test]
async fn catch_up_server_root_walks_a_chain_of_base_rotations() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let (events, roots) = build_base_rekey_chain(&owner, &community, &me.public_key(), 3);
let relay = MemoryRelay::new();
for ev in events.iter().rev() {
relay.inject(ev, &community.relays);
}
let reached = catch_up_server_root(&relay, &community).await.unwrap();
assert_eq!(reached.epoch, 3, "walked the base chain to the latest epoch");
assert!(!reached.removed, "a normal catch-up is not a removal");
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(3));
assert_eq!(reloaded.server_root_key.as_bytes(), &roots[2], "base head is the latest root");
for (i, r) in roots.iter().enumerate() {
assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, (i + 1) as u64).unwrap(), Some(*r));
}
}
#[tokio::test]
async fn catch_up_recovers_from_a_split_base_rotation_second_chunk() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let genesis = *community.server_root_key.as_bytes();
let new_root = [0x5Au8; 32];
let scope = super::super::derive::RekeyScope::ServerRoot;
let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
let mk = |recipient: &nostr_sdk::PublicKey| {
let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient, scope, crate::community::Epoch(1), &new_root).unwrap();
super::super::rekey::build_server_root_rekey_event(
&Keys::generate(), &owner, &genesis, &community.id,
crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
).unwrap()
};
let relay = MemoryRelay::new();
relay.inject(&mk(&Keys::generate().public_key()), &community.relays); relay.inject(&mk(&me.public_key()), &community.relays);
let reached = catch_up_server_root(&relay, &community).await.unwrap();
assert_eq!(reached.epoch, 1, "recovered the split rotation via the second chunk");
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "recovered the new root from chunk 2");
}
#[tokio::test]
async fn catch_up_converges_concurrent_refoundings_on_the_lowest_root() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let genesis = *community.server_root_key.as_bytes();
let scope = super::super::derive::RekeyScope::ServerRoot;
let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
let root_lo = [0x10u8; 32];
let root_hi = [0xF0u8; 32]; let mk = |root: &[u8; 32]| {
let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), root).unwrap();
super::super::rekey::build_server_root_rekey_event(
&Keys::generate(), &owner, &genesis, &community.id,
crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
).unwrap()
};
let relay = MemoryRelay::new();
relay.inject(&mk(&root_hi), &community.relays);
relay.inject(&mk(&root_lo), &community.relays);
let reached = catch_up_server_root(&relay, &community).await.unwrap();
assert_eq!(reached.epoch, 1, "advanced one epoch");
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_key.as_bytes(), &root_lo, "converged on the LOWEST root, not the first-arrived");
}
#[tokio::test]
async fn rotate_retry_reuses_the_archived_root_no_same_epoch_fork() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
become_local(&owner);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let relay = RekeyFailingRelay::new(); let member = Keys::generate();
assert!(rotate_server_root(&relay, &community, &[member.public_key()]).await.is_err(), "the rekey publish fails");
let k1 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap()
.expect("the new root is archived before publishing (fork-safety)");
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "head not advanced on a failed publish");
relay.allow_rekey();
rotate_server_root(&relay, &reloaded, &[member.public_key()]).await.unwrap();
let k2 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap().unwrap();
assert_eq!(k1, k2, "the retry REUSES the archived root — no second root for epoch 1, no fork");
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "the retry completed the rotation");
assert_eq!(after.server_root_key.as_bytes(), &k1, "the committed root is the one minted on attempt 1");
}
#[tokio::test]
async fn rotate_server_root_splits_a_large_recipient_set_into_multiple_events() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
become_local(&owner);
let community = saved_community_owned_by(&owner);
let genesis = *community.server_root_key.as_bytes();
let relay = MemoryRelay::new();
let recipients: Vec<_> = (0..super::super::rekey::MAX_REKEY_BLOBS).map(|_| Keys::generate().public_key()).collect();
rotate_server_root(&relay, &community, &recipients).await.unwrap();
let addr = super::super::derive::base_rekey_pseudonym(&super::super::ServerRootKey(genesis), &community.id, crate::community::Epoch(1)).to_hex();
let evs = relay
.fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
.await
.unwrap();
assert_eq!(evs.len(), 2, "a >MAX_REKEY_BLOBS rotation splits into 2 events at one address");
}
#[tokio::test]
async fn catch_up_server_root_is_a_noop_with_no_rotations() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let relay = MemoryRelay::new();
assert_eq!(catch_up_server_root(&relay, &community).await.unwrap().epoch, 0, "no base rotations → stays at 0");
}
#[tokio::test]
async fn concurrent_refounders_converge_to_the_lowest_root() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me); let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let genesis_root = *community.server_root_key.as_bytes();
let scope = super::super::derive::RekeyScope::ServerRoot;
let root_lo = [0x10u8; 32];
let root_hi = [0x99u8; 32]; let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_lo).unwrap();
let ev_lo = super::super::rekey::build_server_root_rekey_event(
&Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
let relay = MemoryRelay::new();
relay.inject(&ev_lo, &community.relays);
crate::db::community::advance_server_root_epoch(&cid, 1, &root_hi).unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(community.server_root_key.as_bytes(), &root_hi, "start on the higher root");
let out = catch_up_server_root(&relay, &community).await.unwrap();
assert_eq!(out.epoch, 1, "converged in place at the same epoch (not advanced)");
assert!(!out.removed);
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(after.server_root_key.as_bytes(), &root_lo, "originator converged to the lowest authorized root");
let _ = catch_up_server_root(&relay, &after).await.unwrap();
assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_key.as_bytes(), &root_lo, "no flip-flop");
}
#[tokio::test]
async fn banned_rotators_rekey_is_not_a_convergence_candidate() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
let banned_admin = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let genesis_root = *community.server_root_key.as_bytes();
let scope = super::super::derive::RekeyScope::ServerRoot;
let role_id = "e".repeat(64);
let roster = crate::community::roles::CommunityRoles {
roles: vec![crate::community::roles::Role::admin(role_id.clone())],
grants: vec![crate::community::roles::MemberGrant { member: banned_admin.public_key().to_hex(), role_ids: vec![role_id] }],
};
crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
let root_evil = [0x01u8; 32];
let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
let blob = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
let ev = super::super::rekey::build_server_root_rekey_event(
&Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob]).unwrap();
let relay = MemoryRelay::new();
relay.inject(&ev, &community.relays);
let out = catch_up_server_root(&relay, &community).await.unwrap();
assert_eq!(out.epoch, 0, "banned rotator's re-founding must not advance the base");
assert!(!out.removed, "banned rotator's exclusion must not read as an authorized removal");
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(after.server_root_key.as_bytes(), &genesis_root, "root unchanged");
let parsed = super::super::rekey::open_rekey_event(&ev, &genesis_root).unwrap();
assert!(apply_server_root_rekey(&community, &parsed).is_err(), "apply must refuse a banned rotator");
}
#[tokio::test]
async fn heal_abandons_a_deauthorized_root_for_the_authorized_higher_sibling() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
let banned_admin = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let genesis_root = *community.server_root_key.as_bytes();
let scope = super::super::derive::RekeyScope::ServerRoot;
let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
let root_evil = [0x01u8; 32];
let root_owner = [0x77u8; 32];
let blob_evil = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
let ev_evil = super::super::rekey::build_server_root_rekey_event(
&Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_evil]).unwrap();
let blob_owner = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_owner).unwrap();
let ev_owner = super::super::rekey::build_server_root_rekey_event(
&Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_owner]).unwrap();
let relay = MemoryRelay::new();
relay.inject(&ev_evil, &community.relays);
relay.inject(&ev_owner, &community.relays);
crate::db::community::advance_server_root_epoch(&cid, 1, &root_evil).unwrap();
crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(community.server_root_key.as_bytes(), &root_evil, "start partitioned on the attacker's root");
let out = catch_up_server_root(&relay, &community).await.unwrap();
assert_eq!(out.epoch, 1);
assert!(!out.removed);
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(after.server_root_key.as_bytes(), &root_owner,
"heal must abandon the deauthorized root and adopt the owner's higher sibling");
let _ = catch_up_server_root(&relay, &after).await.unwrap();
assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_key.as_bytes(), &root_owner, "no flap back to the banned root");
}
#[tokio::test]
async fn concurrent_channel_rekeyers_converge_to_the_lowest_key() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me); let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let channel_id = community.channels[0].id;
let chan_hex = channel_id.to_hex();
let scope = super::super::derive::RekeyScope::Channel(channel_id);
let genesis_key = *community.channels[0].key.as_bytes();
let root = *community.server_root_key.as_bytes();
let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
let key_lo = [0x10u8; 32];
let key_hi = [0x99u8; 32];
let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
let ev_lo = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
let ev_hi = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
let relay = MemoryRelay::new();
relay.inject(&ev_hi, &community.relays); relay.inject(&ev_lo, &community.relays);
crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
assert_eq!(reached, 1, "converged in place at the same channel epoch");
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
"adopted the lowest delivered key regardless of relay order");
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
let _ = catch_up_channel_rekeys(&relay, &after, &channel_id).await.unwrap();
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo), "no flip-flop");
}
#[tokio::test]
async fn concurrent_channel_rekeyers_converge_when_i_authored_the_losing_fork() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate(); become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let channel_id = community.channels[0].id;
let chan_hex = channel_id.to_hex();
let scope = super::super::derive::RekeyScope::Channel(channel_id);
let genesis_key = *community.channels[0].key.as_bytes();
let root = *community.server_root_key.as_bytes();
let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
let role_id = "d".repeat(64);
let roster = crate::community::roles::CommunityRoles {
roles: vec![crate::community::roles::Role::admin(role_id.clone())],
grants: vec![crate::community::roles::MemberGrant { member: me.public_key().to_hex(), role_ids: vec![role_id] }],
};
crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
let key_lo = [0x10u8; 32]; let key_hi = [0x99u8; 32]; let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
let ev_lo = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
let blob_hi = super::super::rekey::build_rekey_blob(me.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
let ev_hi = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &me, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
let relay = MemoryRelay::new();
relay.inject(&ev_hi, &community.relays);
relay.inject(&ev_lo, &community.relays);
crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
"I authored the losing fork but must converge DOWN to the owner's lower key");
}
#[tokio::test]
async fn reorg_through_a_fork_heals_the_forked_past_epoch() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let channel_id = community.channels[0].id;
let chan_hex = channel_id.to_hex();
let scope = super::super::derive::RekeyScope::Channel(channel_id);
let genesis_key = *community.channels[0].key.as_bytes();
let root = *community.server_root_key.as_bytes();
let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
let key_lo1 = [0x10u8; 32]; let key_hi1 = [0x99u8; 32]; let key_e2 = [0x20u8; 32]; let blob_lo1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo1).unwrap();
let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
let ev_lo1 = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
let ev_hi1 = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
let commit1_win = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &key_lo1);
let blob_e2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &key_e2).unwrap();
let ev_e2 = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit1_win, &[blob_e2]).unwrap();
let relay = MemoryRelay::new();
relay.inject(&ev_lo1, &community.relays);
relay.inject(&ev_hi1, &community.relays);
relay.inject(&ev_e2, &community.relays);
crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
assert_eq!(reached, 2, "reorged forward to the head epoch");
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch adopted");
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
"the FORKED past epoch re-converged to the lowest sibling (its messages become readable)");
}
#[tokio::test]
async fn window_heal_converges_an_already_reorged_past_fork() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let channel_id = community.channels[0].id;
let chan_hex = channel_id.to_hex();
let scope = super::super::derive::RekeyScope::Channel(channel_id);
let genesis_key = *community.channels[0].key.as_bytes();
let root = *community.server_root_key.as_bytes();
let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
let key_lo1 = [0x10u8; 32]; let key_hi1 = [0x99u8; 32]; let key_e2 = [0x20u8; 32]; let blob_lo1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo1).unwrap();
let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
let ev_lo1 = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
let ev_hi1 = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
let relay = MemoryRelay::new();
relay.inject(&ev_lo1, &community.relays);
relay.inject(&ev_hi1, &community.relays);
crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &key_e2).unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
assert_eq!(reached, 2, "head unchanged (no new rekey to apply)");
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch untouched");
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
"the already-forked past epoch re-converged to the lowest sibling via the window heal (no in-sync reorg)");
}
#[tokio::test]
async fn channel_heal_cannot_converge_to_a_key_i_was_not_given() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let channel_id = community.channels[0].id;
let chan_hex = channel_id.to_hex();
let scope = super::super::derive::RekeyScope::Channel(channel_id);
let genesis_key = *community.channels[0].key.as_bytes();
let root = *community.server_root_key.as_bytes();
let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
let key_lo = [0x10u8; 32]; let key_hi = [0x99u8; 32]; let other = Keys::generate();
let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
let ev_lo = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
let ev_hi = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
let relay = MemoryRelay::new();
relay.inject(&ev_lo, &community.relays);
relay.inject(&ev_hi, &community.relays);
crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_hi),
"excluded from the winning rekey ⇒ cannot converge");
}
#[tokio::test]
async fn refounding_channel_rekey_is_sealed_under_the_prior_root() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
become_local(&owner); let community = saved_community_owned_by(&owner);
let channel_id = community.channels[0].id;
let prior_root = [0x11u8; 32];
let relay = MemoryRelay::new();
rotate_channel(&relay, &community, &channel_id, &[owner.public_key()], &prior_root).await.unwrap();
let z = super::super::derive::rekey_pseudonym(&crate::community::ServerRootKey(prior_root), &channel_id, crate::community::Epoch(1)).to_hex();
let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![z], ..Default::default() };
let evs = relay.fetch(&q, &community.relays).await.unwrap();
assert_eq!(evs.len(), 1, "channel rekey is addressed at the PRIOR-root pseudonym");
assert!(super::super::rekey::open_rekey_event(&evs[0], &prior_root).is_ok(),
"opens under the prior (shared) root every retained member still holds");
assert!(super::super::rekey::open_rekey_event(&evs[0], community.server_root_key.as_bytes()).is_err(),
"does NOT open under the current/new root (which a base-fork loser would have dropped)");
}
#[tokio::test]
async fn apply_channel_rekey_converges_past_a_divergent_prior_epoch() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let channel_id = community.channels[0].id;
let chan_hex = channel_id.to_hex();
let scope = super::super::derive::RekeyScope::Channel(channel_id);
let root = *community.server_root_key.as_bytes();
let my_fork_key = [0xAAu8; 32];
crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &my_fork_key).unwrap();
let winner_epoch1 = [0xBBu8; 32];
let new_key = [0x22u8; 32];
let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &winner_epoch1);
let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &new_key).unwrap();
let ev = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit, &[blob]).unwrap();
let parsed = super::super::rekey::open_rekey_event(&ev, &root).unwrap();
let outcome = apply_channel_rekey(&community, &parsed).unwrap();
assert!(matches!(outcome, RekeyOutcome::Applied { head_advanced: true }),
"must converge forward past the divergent prior epoch, got {outcome:?}");
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(new_key),
"adopted the winner's epoch-2 key");
}
#[tokio::test]
async fn catch_up_server_root_stops_when_removed_from_base() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let scope = super::super::derive::RekeyScope::ServerRoot;
let relay = MemoryRelay::new();
let root1 = [0x11u8; 32];
let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root1).unwrap();
let e1 = super::super::rekey::build_server_root_rekey_event(
&Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
crate::community::Epoch(1), crate::community::Epoch(0),
&super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), community.server_root_key.as_bytes()), &[b1],
).unwrap();
let other = Keys::generate();
let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
let e2 = super::super::rekey::build_server_root_rekey_event(
&Keys::generate(), &owner, &root1, &community.id,
crate::community::Epoch(2), crate::community::Epoch(1),
&super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &root1), &[b2],
).unwrap();
relay.inject(&e1, &community.relays);
relay.inject(&e2, &community.relays);
let reached = catch_up_server_root(&relay, &community).await.unwrap();
assert_eq!(reached.epoch, 1, "stops at the last base epoch I was a recipient of");
assert!(reached.removed, "excluded by an AUTHORIZED (owner) base rotation → flagged removed so the caller erases");
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
}
#[tokio::test]
async fn catch_up_is_a_noop_with_no_rotations() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let relay = MemoryRelay::new(); let reached = catch_up_channel_rekeys(&relay, &community, &community.channels[0].id).await.unwrap();
assert_eq!(reached, 0, "no rotations → stays at the held epoch");
}
#[tokio::test]
async fn catch_up_stops_when_removed_midway() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let channel_id = community.channels[0].id;
let chan = &community.channels[0];
let scope = super::super::derive::RekeyScope::Channel(channel_id);
let relay = MemoryRelay::new();
let k1 = [0x11u8; 32];
let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
let e1 = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
crate::community::Epoch(1), crate::community::Epoch(0),
&super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes()), &[b1],
).unwrap();
let other = Keys::generate();
let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
let e2 = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
crate::community::Epoch(2), crate::community::Epoch(1),
&super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1), &[b2],
).unwrap();
relay.inject(&e1, &community.relays);
relay.inject(&e2, &community.relays);
let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
assert_eq!(reached, 1, "stops at the last epoch I was a recipient of");
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
}
#[tokio::test]
async fn rotate_channel_rejects_unauthorized() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let rogue = Keys::generate();
become_local(&rogue); let community = saved_community_owned_by(&owner);
let relay = MemoryRelay::new();
assert!(
rotate_channel(&relay, &community, &community.channels[0].id, &[], community.server_root_key.as_bytes()).await.is_err(),
"a non-authorized member cannot rotate"
);
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
}
#[tokio::test]
async fn rotate_server_root_publishes_recoverable_rekey_and_advances_base() {
use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
become_local(&owner); let community = saved_community_owned_by(&owner);
let genesis_root = *community.server_root_key.as_bytes();
let member = Keys::generate();
let relay = MemoryRelay::new();
let new_epoch = rotate_server_root(&relay, &community, &[member.public_key()]).await.expect("rotate base");
assert_eq!(new_epoch, 1);
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
assert_ne!(reloaded.server_root_key.as_bytes(), &genesis_root, "base root is fresh-random, not the genesis");
let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
let found = relay
.fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
.await
.unwrap();
assert_eq!(found.len(), 1, "base rekey addressable by its prior-root pseudonym");
let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
assert!(matches!(parsed.scope, crate::community::derive::RekeyScope::ServerRoot));
assert_eq!(parsed.rotator, owner.public_key());
assert_eq!(parsed.blobs.len(), 2, "member + me (multi-device)");
let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "member's recovered root == owner's advanced base head");
}
#[tokio::test]
async fn rotate_server_root_failed_publish_leaves_base_unadvanced() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
become_local(&owner);
let community = saved_community_owned_by(&owner);
let member = Keys::generate();
assert!(rotate_server_root(&FailingRelay, &community, &[member.public_key()]).await.is_err());
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head stays put on publish failure");
}
#[tokio::test]
async fn rotate_server_root_dedups_self_in_recipients() {
use crate::community::rekey::open_rekey_event;
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
become_local(&owner);
let community = saved_community_owned_by(&owner);
let relay = MemoryRelay::new();
rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
let addr = crate::community::derive::base_rekey_pseudonym(
&crate::community::ServerRootKey(*community.server_root_key.as_bytes()), &community.id, crate::community::Epoch(1),
)
.to_hex();
let found = relay
.fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
.await
.unwrap();
let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
assert_eq!(parsed.blobs.len(), 1, "self listed in recipients yields exactly one blob, not two");
}
#[tokio::test]
async fn rotate_server_root_rejects_unauthorized() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let rogue = Keys::generate();
become_local(&rogue); let community = saved_community_owned_by(&owner);
let relay = MemoryRelay::new();
assert!(rotate_server_root(&relay, &community, &[]).await.is_err(), "a non-BAN member cannot rotate the base");
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0));
}
#[tokio::test]
async fn rotate_server_root_reanchors_the_control_plane_to_the_new_epoch() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
assert_eq!(crate::db::community::edition_head_entity_ids(&cid).unwrap().len(), 3);
let member = Keys::generate();
assert_eq!(rotate_server_root(&relay, &community, &[member.public_key()]).await.unwrap(), 1);
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "base head advanced");
let z = crate::community::roster::control_pseudonym(&reloaded.server_root_key, &community.id, crate::community::Epoch(1));
let evs = relay
.fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays)
.await
.unwrap();
let inners: Vec<_> = evs
.iter()
.filter_map(|o| crate::community::roster::open_control_edition(o, &reloaded.server_root_key).ok())
.collect();
let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
assert!(!folded.roles.roles.is_empty(), "control plane re-anchored at the new epoch as part of the rotation");
}
#[tokio::test]
async fn admin_refounding_carries_heads_verbatim_preserving_owner_and_peer_roles() {
use crate::community::roles::Permissions;
let (_tmp, _guard) = init_test_db();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let owner_hex = owner.public_key().to_hex();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
let alice = Keys::generate();
let bob = Keys::generate();
set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
let _ = fetch_and_apply_control(&relay, &community).await;
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
let mut edited = community.clone();
edited.name = "HQ renamed".into();
republish_community_metadata(&relay, &edited).await.unwrap();
let _ = fetch_and_apply_control(&relay, &community).await;
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0 >= 2, "GroupRoot now above v1");
become_local(&alice);
let new_epoch = rotate_server_root(&relay, &community, &[owner.public_key(), bob.public_key()]).await.unwrap();
assert_eq!(new_epoch, 1);
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(1));
let evs = relay.fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays).await.unwrap();
let inners: Vec<_> = evs.iter().filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok()).collect();
let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
let authed = crate::community::roster::authorize_delegation(&folded, Some(&owner_hex));
assert!(authed.is_authorized(&alice.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "alice (re-founder) still admin");
assert!(authed.is_authorized(&bob.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "bob (peer admin) NOT demoted by alice's re-founding");
let new_owner = folded.root_meta.as_ref().and_then(|m| m.owner_attestation.as_ref())
.and_then(|j| Event::from_json(j).ok()).map(|e| e.pubkey.to_hex());
assert_eq!(new_owner.as_deref(), Some(owner_hex.as_str()), "owner deed carried verbatim — ownership intact after an admin re-founding");
assert_eq!(folded.root_meta.as_ref().map(|m| m.name.as_str()), Some("HQ renamed"),
"the >v1 GroupRoot head carried verbatim (content preserved across the re-founding)");
let mut per_entity: std::collections::HashMap<[u8; 32], usize> = std::collections::HashMap::new();
for i in &inners {
if let Ok(p) = crate::community::edition::parse_edition_inner(i) { *per_entity.entry(p.entity_id).or_default() += 1; }
}
assert!(per_entity.values().all(|&c| c == 1), "one edition per entity at the new epoch (compacted)");
}
#[tokio::test]
async fn admin_write_blocked_when_isolated() {
let (_tmp, _guard) = init_test_db();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&me);
let cid = community.id.to_hex();
crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[1u8; 32], &[1u8; 32]).unwrap();
crate::db::community::set_read_cut_target_epoch(&cid, 1).unwrap();
let err = reseal_base_to_observed(&FailingRelay, &community).await.unwrap_err();
assert!(err.contains("offline") || err.contains("can't reach any relay"),
"isolated admin write must fail closed, got: {err}");
assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
crate::community::Epoch(0), "no rotation while isolated");
}
#[tokio::test]
async fn refounding_rotates_channel_keys_too() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let channel_id = community.channels[0].id;
assert_eq!(community.channels[0].epoch, crate::community::Epoch(0));
assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
run_read_cut(&relay, &community, true).await.unwrap();
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "base rotated");
let ch = after.channels.iter().find(|c| c.id == channel_id).unwrap();
assert_eq!(ch.epoch, crate::community::Epoch(1), "channel key rotated too (O2)");
assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&community.id.to_hex(), &channel_id.to_hex()).unwrap(),
1, "channel marked rekeyed for the new base epoch");
assert!(!crate::db::community::get_read_cut_pending(&community.id.to_hex()).unwrap(),
"a complete read-cut clears the pending flag");
}
#[tokio::test]
async fn read_cut_resumes_without_double_base_rotation_after_channel_failure() {
struct ChannelRekeyFails {
inner: MemoryRelay,
rekeys: std::sync::atomic::AtomicUsize,
fail_channel: std::sync::atomic::AtomicBool,
}
#[async_trait::async_trait]
impl Transport for ChannelRekeyFails {
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> {
if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
let n = self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if n >= 1 && self.fail_channel.load(std::sync::atomic::Ordering::Relaxed) {
return Err("channel rekey relay down".into());
}
}
self.inner.publish_durable(e, r).await
}
async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
}
let (_tmp, _guard) = init_test_db();
let relay = ChannelRekeyFails {
inner: MemoryRelay::new(),
rekeys: std::sync::atomic::AtomicUsize::new(0),
fail_channel: std::sync::atomic::AtomicBool::new(true),
};
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let channel_id = community.channels[0].id;
let cid = community.id.to_hex();
let ch_hex = channel_id.to_hex();
assert!(run_read_cut(&relay, &community, true).await.is_err(), "the channel failure surfaces an error");
let mid = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(mid.server_root_epoch, crate::community::Epoch(1), "base advanced exactly once");
assert_eq!(mid.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(0),
"channel NOT rotated (its rekey failed)");
assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "cut left pending after the failure");
assert_eq!(crate::db::community::get_read_cut_target_epoch(&cid).unwrap(), 1, "target recorded durably");
assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 0,
"channel not yet marked for this cut");
relay.fail_channel.store(false, std::sync::atomic::Ordering::Relaxed);
retry_pending_read_cut(&relay, &mid).await.unwrap();
let done = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(done.server_root_epoch, crate::community::Epoch(1),
"base NOT rotated again — resumed at the same epoch (no double base rotation)");
assert_eq!(done.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(1),
"the un-rotated channel finished on resume");
assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 1,
"channel marked rekeyed for the cut epoch");
assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the resume completes");
}
#[tokio::test]
async fn rotate_server_root_aborts_when_the_snapshot_does_not_land() {
struct ControlPublishFails { inner: MemoryRelay, fail: std::sync::atomic::AtomicBool }
#[async_trait::async_trait]
impl Transport for ControlPublishFails {
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> {
if self.fail.load(std::sync::atomic::Ordering::Relaxed) && e.kind.as_u16() == event_kind::COMMUNITY_CONTROL {
return Err("control relay down".into());
}
self.inner.publish_durable(e, r).await
}
async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
}
let (_tmp, _guard) = init_test_db();
let relay = ControlPublishFails { inner: MemoryRelay::new(), fail: std::sync::atomic::AtomicBool::new(false) };
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
relay.fail.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(
rotate_server_root(&relay, &community, &[]).await.is_err(),
"a snapshot whose editions can't be re-published must abort the rotation"
);
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced when the snapshot doesn't land");
}
#[tokio::test]
async fn acquire_before_commit_a_reanchor_fetch_miss_publishes_no_base_rekey() {
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
struct ReanchorFetchEmpty { inner: MemoryRelay, drop_control: AtomicBool, base_rekeys: AtomicUsize }
#[async_trait::async_trait]
impl Transport for ReanchorFetchEmpty {
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> {
if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
self.base_rekeys.fetch_add(1, Ordering::Relaxed);
}
self.inner.publish_durable(e, r).await
}
async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
if self.drop_control.load(Ordering::Relaxed) && q.kinds.iter().any(|k| *k == event_kind::COMMUNITY_CONTROL) {
return Ok(vec![]); }
self.inner.fetch(q, r).await
}
}
let (_tmp, _guard) = init_test_db();
let relay = ReanchorFetchEmpty { inner: MemoryRelay::new(), drop_control: AtomicBool::new(false), base_rekeys: AtomicUsize::new(0) };
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
relay.drop_control.store(true, Ordering::Relaxed);
assert!(rotate_server_root(&relay, &community, &[]).await.is_err(),
"a re-anchor fetch miss must abort the rotation");
assert_eq!(relay.base_rekeys.load(Ordering::Relaxed), 0,
"the base rekey must NOT be published when the pre-publish fetch gate trips (acquire-before-commit)");
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced");
}
#[tokio::test]
async fn reanchor_carries_role_and_grant_to_the_new_epoch_under_the_new_root() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
let member = Keys::generate();
set_member_grant(&relay, &community, &member.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
let _ = fetch_and_apply_control(&relay, &community).await;
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
let new_root = [0x99u8; 32];
let snap = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
assert!(snap.iter().all(|e| e.published), "every snapshot edition published");
assert_eq!(snap.len(), 4, "GroupRoot + channel + Admin role + grant compacted to v1");
let new_z = crate::community::roster::control_pseudonym(
&crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
);
let after = relay
.fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
.await
.unwrap();
let inners: Vec<_> = after
.iter()
.filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
.collect();
let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
assert!(!folded.roles.roles.is_empty(), "Admin role reachable at the new epoch");
assert!(
folded.roles.grants.iter().any(|g| g.member == member.public_key().to_hex()),
"grant carried to the new epoch under the new root"
);
}
#[tokio::test]
async fn grant_after_a_rekey_survives_the_fold_at_the_new_epoch() {
use crate::community::roles::Permissions;
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
rotate_server_root(&relay, &community, &[owner.public_key()]).await.expect("rotate base");
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(community.server_root_epoch, crate::community::Epoch(1), "advanced to the new epoch");
let alice = "aa".repeat(32);
set_member_grant(&relay, &community, &alice, vec![admin_role_id]).await.unwrap();
let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
assert!(
roster.has_permission(&alice, Permissions::BAN),
"post-rekey grant survives — Alice is Admin at the new epoch (pre-fix: dropped, role unreachable)"
);
assert_eq!(roster.highest_position(&alice), Some(1));
}
#[tokio::test]
async fn demote_re_asserts_the_demoted_members_metadata_head() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
let alice = Keys::generate();
let alice_hex = alice.public_key().to_hex();
set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
become_local(&alice);
let mut as_alice = crate::db::community::load_community(&community.id).unwrap().unwrap();
as_alice.name = "Alice's HQ".into();
republish_community_metadata(&relay, &as_alice).await.unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(
fetch_control_folded(&relay, &community).await.unwrap().root_author.map(|a| a.to_hex()),
Some(alice_hex.clone()), "alice heads the GroupRoot after her edit",
);
become_local(&owner);
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
let folded = fetch_control_folded(&relay, &community).await.unwrap();
assert_eq!(folded.root_author.map(|a| a.to_hex()), Some(owner.public_key().to_hex()),
"the demote re-asserted the GroupRoot under the owner");
assert_eq!(folded.root_meta.as_ref().unwrap().name, "Alice's HQ",
"the re-assert preserves the demoted member's content");
}
#[tokio::test]
async fn demote_skips_reassert_when_member_does_not_head() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
let alice = Keys::generate();
let alice_hex = alice.public_key().to_hex();
set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
c.name = "Owner's HQ".into();
republish_community_metadata(&relay, &c).await.unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
let before = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
let after = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
assert_eq!(after, before, "no re-assert published — the demoted member didn't head the GroupRoot");
}
#[tokio::test]
async fn reanchor_carries_the_banlist_edition_to_the_new_epoch() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let carol = "cc".repeat(32);
publish_banlist(&relay, &community, &[carol.clone()]).await.unwrap();
let _ = fetch_and_apply_control(&relay, &community).await;
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
let new_root = [0x99u8; 32];
let n = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
assert!(n.iter().all(|e| e.published), "every snapshot edition published");
assert_eq!(n.len(), 4, "GroupRoot + channel + Admin role + banlist compacted to v1");
let new_z = crate::community::roster::control_pseudonym(
&crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
);
let after = relay
.fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
.await
.unwrap();
let inners: Vec<_> = after
.iter()
.filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
.collect();
let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
assert_eq!(folded.banned, vec![carol], "banlist reachable at the new epoch under the new root");
}
fn owner_base_rekey(
owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::PublicKey, new_epoch: u64, new_root: &[u8; 32],
) -> super::super::rekey::ParsedRekey {
let prev = community.server_root_epoch.0;
let blob = super::super::rekey::build_rekey_blob(
owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(new_epoch), new_root,
)
.unwrap();
let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(prev), community.server_root_key.as_bytes());
let outer = super::super::rekey::build_server_root_rekey_event(
&Keys::generate(), owner, community.server_root_key.as_bytes(), &community.id,
crate::community::Epoch(new_epoch), crate::community::Epoch(prev), &commit, &[blob],
)
.unwrap();
super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
}
#[test]
fn apply_server_root_rekey_recovers_new_root_and_advances_base() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let new_root = [0xCDu8; 32];
let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &new_root);
assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "base head advanced to the new root");
assert!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().is_some());
assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(), Some(new_root));
}
#[test]
fn apply_server_root_rekey_not_a_recipient_leaves_base_unchanged() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let other = Keys::generate(); let parsed = owner_base_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "removed-from-base member's head unchanged");
}
#[test]
fn apply_server_root_rekey_rejects_rotator_without_ban() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let rogue = Keys::generate();
let parsed = owner_base_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
assert!(apply_server_root_rekey(&community, &parsed).is_err(), "unauthorized base rotation rejected");
}
#[test]
fn apply_server_root_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let blob = super::super::rekey::build_rekey_blob(
owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(1), &[0x33u8; 32],
)
.unwrap();
let bad = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
let outer = super::super::rekey::build_server_root_rekey_event(
&Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
crate::community::Epoch(1), crate::community::Epoch(0), &bad, &[blob],
)
.unwrap();
let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
let outcome = apply_server_root_rekey(&community, &parsed);
assert!(
matches!(outcome, Ok(RekeyOutcome::Applied { .. })),
"an authorized base chain must be adopted (reorg), not rejected as foreign; got {outcome:?}"
);
}
#[test]
fn apply_server_root_rekey_catchup_archives_without_regressing_base_head() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let r5 = [0x55u8; 32];
let p5 = owner_base_rekey(&owner, &community, &me.public_key(), 5, &r5);
assert_eq!(apply_server_root_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
let r3 = [0x33u8; 32];
let p3 = owner_base_rekey(&owner, &community, &me.public_key(), 3, &r3);
assert_eq!(apply_server_root_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 3).unwrap(), Some(r3));
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5), "base head stayed at newest");
assert_eq!(reloaded.server_root_key.as_bytes(), &r5);
}
#[test]
fn apply_server_root_rekey_authorizes_a_granted_ban_admin() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let admin = Keys::generate();
let role_id = "d".repeat(64);
let roster = crate::community::roles::CommunityRoles {
roles: vec![crate::community::roles::Role::admin(role_id.clone())],
grants: vec![crate::community::roles::MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role_id] }],
};
crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
let parsed = owner_base_rekey(&admin, &community, &me.public_key(), 1, &[0x77u8; 32]);
assert_eq!(
apply_server_root_rekey(&community, &parsed).unwrap(),
RekeyOutcome::Applied { head_advanced: true },
"a BAN-granted admin (not the owner) can rotate the base"
);
}
#[test]
fn apply_server_root_rekey_accepts_when_prior_root_not_held() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let new_root = [0x99u8; 32];
let blob = super::super::rekey::build_rekey_blob(
owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(5), &new_root,
)
.unwrap();
let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(4), &[0xEEu8; 32]);
let outer = super::super::rekey::build_server_root_rekey_event(
&Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
crate::community::Epoch(5), crate::community::Epoch(4), &commit, &[blob],
)
.unwrap();
let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5));
}
#[test]
fn apply_server_root_rekey_rejects_channel_scope() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let channel_parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
assert!(apply_server_root_rekey(&community, &channel_parsed).is_err(), "channel scope rejected by base apply");
}
#[test]
fn apply_channel_rekey_not_a_recipient() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let other = Keys::generate();
let parsed = owner_channel_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
assert_eq!(apply_channel_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
}
#[test]
fn apply_channel_rekey_rejects_unauthorized_rotator() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let rogue = Keys::generate();
let parsed = owner_channel_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
assert!(apply_channel_rekey(&community, &parsed).is_err(), "unauthorized rotation must be rejected");
}
#[test]
fn apply_channel_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let chan = &community.channels[0];
let scope = super::super::derive::RekeyScope::Channel(chan.id);
let new_key = [0x33u8; 32];
let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &new_key).unwrap();
let other_commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
let outer = super::super::rekey::build_channel_rekey_event(
&Keys::generate(), &owner, community.server_root_key.as_bytes(), &chan.id,
crate::community::Epoch(1), crate::community::Epoch(0), &other_commit, &[blob],
)
.unwrap();
let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
let outcome = apply_channel_rekey(&community, &parsed).unwrap();
assert!(matches!(outcome, RekeyOutcome::Applied { .. }),
"an authorized chain must be adopted (reorg), not rejected as foreign; got {outcome:?}");
assert_eq!(crate::db::community::held_epoch_key(&community.id.to_hex(), &chan.id.to_hex(), 1).unwrap(), Some(new_key));
}
#[test]
fn apply_channel_rekey_catchup_archives_without_regressing_head() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
let chan_hex = community.channels[0].id.to_hex();
let k5 = [0x55u8; 32];
let p5 = owner_channel_rekey(&owner, &community, &me.public_key(), 5, &k5);
assert_eq!(apply_channel_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
let k3 = [0x33u8; 32];
let p3 = owner_channel_rekey(&owner, &community, &me.public_key(), 3, &k3);
assert_eq!(apply_channel_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(k3), "old epoch archived");
assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 5).unwrap(), Some(k5));
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(5), "head stayed at the newest epoch");
assert_eq!(reloaded.channels[0].key.as_bytes(), &k5);
}
#[tokio::test]
async fn create_community_persists_and_publishes_metadata() {
use crate::community::transport::Query;
use crate::stored_event::event_kind;
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "Vector HQ", "general", vec!["r1".into()])
.await
.expect("create");
assert_eq!(community.name, "Vector HQ");
assert_eq!(community.channels.len(), 1);
assert_eq!(community.channels[0].name, "general");
let loaded = crate::db::community::load_community(&community.id).unwrap().expect("persisted");
assert_eq!(loaded.channels[0].name, "general");
assert_eq!(loaded.server_root_key.as_bytes(), community.server_root_key.as_bytes());
let meta_events = relay
.fetch(
&Query { kinds: vec![event_kind::APPLICATION_SPECIFIC], ..Default::default() },
&community.relays,
)
.await
.unwrap();
assert!(meta_events.is_empty(), "no legacy 30078 metadata events");
let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
let control = relay
.fetch(
&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() },
&community.relays,
)
.await
.unwrap();
assert_eq!(control.len(), 3, "GroupRoot + ChannelMetadata + Admin role editions");
let owner_pk = crate::state::my_public_key().unwrap();
let parsed: Vec<_> = control
.iter()
.filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok())
.filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
.collect();
assert!(parsed.iter().all(|p| p.author == owner_pk), "every genesis edition authored by the owner");
let root = parsed.iter().find(|p| p.entity_id == community.id.0).expect("GroupRoot edition");
let root_meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&root.content).unwrap();
assert_eq!(root_meta.name, "Vector HQ");
assert!(root_meta.owner_attestation.is_some());
let role: crate::community::roles::Role = parsed
.iter()
.find_map(|p| serde_json::from_str::<crate::community::roles::Role>(&p.content).ok().filter(|r| r.name == "Admin"))
.expect("Admin role edition");
assert_eq!(role.position, 1);
assert!(role.permissions.contains(crate::community::roles::Permissions::ADMIN_ALL));
let cached = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap();
assert_eq!(cached.roles.len(), 1);
assert!(cached.grants.is_empty(), "owner is implicit position 0, takes no grant");
}
#[tokio::test]
async fn role_grant_round_trips_through_relays_and_revokes() {
use crate::community::roles::Permissions;
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
.await
.expect("create");
let cid = community.id.to_hex();
let alice = "aa".repeat(32);
let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0]
.role_id
.clone();
set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()])
.await
.unwrap();
assert!(
crate::db::community::get_community_roles(&cid).unwrap().is_privileged(&alice),
"local cache reflects the grant immediately"
);
let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
assert!(roster.has_permission(&alice, Permissions::BAN));
assert!(roster.has_permission(&alice, Permissions::MANAGE_ROLES));
assert_eq!(roster.roles.len(), 1);
assert_eq!(roster.highest_position(&alice), Some(1));
set_member_grant(&relay, &community, &alice, vec![]).await.unwrap();
let after = crate::db::community::get_community_roles(&cid).unwrap();
assert!(!after.is_privileged(&alice), "revoked member holds no role");
assert!(after.grants.is_empty(), "empty grant pruned");
}
#[tokio::test]
async fn admin_cannot_grant_a_peer_rank_role() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
.await
.expect("create");
let cid = community.id.to_hex();
let admin_role_id =
crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
let alice = Keys::generate();
set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
.await
.unwrap();
crate::state::set_my_public_key(alice.public_key());
let bob = Keys::generate().public_key();
let err = grant_role(&relay, &community, bob, &admin_role_id).await.unwrap_err();
assert!(err.contains("below your own"), "peer-rank grant refused, got: {err}");
}
#[tokio::test]
async fn create_community_mints_a_verifiable_owner_attestation() {
let (_tmp, _guard) = init_test_db();
let me = crate::state::my_public_key().unwrap();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
.await
.expect("create");
let att = community.owner_attestation.as_ref().expect("attestation is mandatory");
let proven = super::super::owner::verify_owner_attestation(att, &community.id.to_hex());
assert_eq!(proven, Some(me), "the creator is the proven owner");
assert_eq!(
super::super::owner::verify_owner_attestation(att, &"f".repeat(64)),
None,
);
}
#[tokio::test]
async fn admin_cannot_ban_a_peer_admin() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
.await
.expect("create");
let cid = community.id.to_hex();
let admin_role_id =
crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
let alice = Keys::generate();
let bob = Keys::generate();
set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
.await
.unwrap();
set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
.await
.unwrap();
become_local(&alice);
let err = publish_banlist(&relay, &community, &[bob.public_key().to_hex()])
.await
.unwrap_err();
assert!(err.contains("outranks you"), "peer-admin ban refused, got: {err}");
}
#[tokio::test]
async fn roster_reconstructs_purely_from_relay() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let admin_role_id =
crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
let alice = "aa".repeat(32);
set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()]).await.unwrap();
crate::db::community::set_community_roles(&cid, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
assert!(crate::db::community::get_community_roles(&cid).unwrap().roles.is_empty(), "cache wiped");
let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
assert!(roster.is_admin(&alice), "roster reconstructed from relay editions, not the cache");
assert_eq!(roster.roles.len(), 1, "the Admin role edition folded back");
}
#[tokio::test]
async fn admin_cannot_unban_a_peer_admin() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let admin_role_id =
crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
let alice = Keys::generate();
let bob = Keys::generate();
set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
.await
.unwrap();
set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
.await
.unwrap();
crate::db::community::set_community_banlist(&cid, &[bob.public_key().to_hex()], 1000).unwrap();
become_local(&alice);
let err = publish_banlist(&relay, &community, &[]).await.unwrap_err();
assert!(err.contains("unban"), "unbanning a peer admin refused, got: {err}");
}
#[tokio::test]
async fn create_community_rejects_signer_identity_mismatch() {
let (_tmp, _guard) = init_test_db(); let other = Keys::generate();
crate::state::set_my_public_key(other.public_key()); let relay = MemoryRelay::new();
let err = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap_err();
assert!(err.contains("identity signer"), "signer mismatch refused, got: {err}");
}
#[tokio::test]
async fn banlist_newer_edition_applies_older_is_refused() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
.await
.expect("create");
let id_hex = community.id.to_hex();
let banlist_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::banlist_locator(&community.id));
let mallory = "aa".repeat(32);
let bob = "bb".repeat(32);
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[mallory.clone()], 1, None, 1000, None).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
assert_eq!(applied, vec![mallory.clone()]);
let (head_v, _) = crate::db::community::get_edition_head(&id_hex, &banlist_entity).unwrap().unwrap();
assert_eq!(head_v, 1, "banlist edition head advanced to v1");
crate::db::community::set_community_banlist(&id_hex, &[mallory.clone(), bob.clone()], 2).unwrap();
crate::db::community::set_edition_head(&id_hex, &banlist_entity, 2, &[0x22u8; 32]).unwrap();
let after = fetch_and_apply_banlist(&relay, &community).await.unwrap();
assert_eq!(after, vec![mallory, bob], "older relay edition refused, local banlist preserved");
}
#[tokio::test]
async fn unauthorized_banlist_edition_is_rejected() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let bob = "bb".repeat(32);
let mallory = Keys::generate();
let inner = crate::community::roster::build_banlist_edition(&mallory, &community.id, &[bob], 1, None, 1000, None).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
assert!(applied.is_empty(), "an unauthorized signer's banlist edition is rejected");
}
#[tokio::test]
async fn banlist_receiver_enforces_per_target_outrank() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
let alice = Keys::generate();
let bob = Keys::generate();
set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()]).await.unwrap();
set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
let cite = authority_citation(&community, &alice.public_key().to_hex());
let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[bob.public_key().to_hex()], 1, None, 1000, cite.as_ref()).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
assert!(applied.is_empty(), "an admin can't ban a peer admin (receiver-side outrank)");
}
#[tokio::test]
async fn banlist_admin_bans_regular_member_applies() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
let alice = Keys::generate();
set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
let carol = "cc".repeat(32);
let cite = authority_citation(&community, &alice.public_key().to_hex());
let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol.clone()], 1, None, 1000, cite.as_ref()).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
assert_eq!(applied, vec![carol], "an admin's ban of a regular member applies");
}
#[tokio::test]
async fn owner_banlist_needs_no_citation() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let victim = "cc".repeat(32);
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[victim.clone()], 1, None, 1000, None).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
assert_eq!(applied, vec![victim], "an owner's uncited ban applies");
}
#[tokio::test]
async fn banlist_with_forged_citation_hash_is_rejected() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
let alice = Keys::generate();
set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
let carol = "cc".repeat(32);
let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
cite.edition_hash = [0xEE; 32];
let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
assert!(applied.is_empty(), "a forged-hash citation is rejected");
}
#[tokio::test]
async fn banlist_citing_unsynced_future_version_is_rejected() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
let alice = Keys::generate();
set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
let carol = "cc".repeat(32);
let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
cite.version += 5; let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
assert!(applied.is_empty(), "citing an unsynced future grant version fails closed");
}
#[tokio::test]
async fn demoted_banner_superseded_ban_is_rejected() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
let alice = Keys::generate();
set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
let carol = "cc".repeat(32);
let cite = authority_citation(&community, &alice.public_key().to_hex());
let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![]).await.unwrap();
let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
assert!(applied.is_empty(), "a since-demoted banner's stale ban is rejected (refuse-superseded)");
}
#[tokio::test]
async fn withheld_revocation_cannot_resurrect_a_demoted_banners_grant() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
let alice = Keys::generate();
set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
let carol = "cc".repeat(32);
let cite = authority_citation(&community, &alice.public_key().to_hex());
let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
let alice_bytes = alice.public_key().to_bytes();
let grant_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::grant_locator(&community.id, &alice_bytes));
crate::db::community::set_edition_head(&cid, &grant_entity, 2, &[0xAB; 32]).unwrap();
let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
assert!(applied.is_empty(), "a withheld revocation can't roll the banner's grant back to re-authorize them");
}
#[tokio::test]
async fn invite_registry_round_trips_and_drives_is_public() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
assert!(!is_public(&community).unwrap(), "a fresh community is Private");
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let loc = "1a".repeat(32);
let inner = crate::community::roster::build_invite_links_edition(&owner, &community.id, &[loc.clone()], 1, None, 1000, None).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
assert_eq!(applied, vec![loc], "the owner's link edition folds + unions from the relay");
assert!(is_public(&community).unwrap(), "mode recomputed Public from the folded aggregate");
publish_my_invite_links(&relay, &community, &[]).await.unwrap();
let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
assert!(applied.is_empty() && !is_public(&community).unwrap(), "an empty aggregate is Private");
}
#[tokio::test]
async fn metadata_edit_round_trips_to_a_lagging_member() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let (genesis_v, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
assert_eq!(genesis_v, 1);
let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
edited.name = "Renamed HQ".into();
edited.description = Some("now with a topic".into());
let inner = crate::community::roster::build_community_root_edition(&owner, &community.id, &edited, 2, Some(&genesis_hash), 4000, None).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
fetch_and_apply_metadata(&relay, &community).await.unwrap();
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(after.name, "Renamed HQ", "the owner's GroupRoot edit folded from the relay");
assert_eq!(after.description.as_deref(), Some("now with a topic"));
assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "head advanced to v2");
}
#[tokio::test]
async fn unauthorized_metadata_edit_is_ignored() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
let mallory = Keys::generate();
let mut hacked = crate::community::metadata::CommunityMetadata::of(&community);
hacked.name = "Pwned".into();
let inner = crate::community::roster::build_community_root_edition(&mallory, &community.id, &hacked, 2, Some(&genesis_hash), 5000, None).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
fetch_and_apply_metadata(&relay, &community).await.unwrap();
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(after.name, "HQ", "a non-manage-metadata signer's GroupRoot edit is rejected");
assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 1, "an unauthorized edit never advances the head");
}
#[tokio::test]
async fn channel_rename_round_trips_from_owner_edition() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let channel = community.channels[0].clone();
let ch_hex = channel.id.to_hex();
let (_, genesis_ch_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
let meta = crate::community::metadata::ChannelMetadata { name: "announcements".into() };
let inner = crate::community::roster::build_channel_metadata_edition(&owner, &channel.id, &meta, 2, Some(&genesis_ch_hash), 6000, None).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
fetch_and_apply_metadata(&relay, &community).await.unwrap();
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(after.channels[0].name, "announcements", "the owner's channel rename folded + applied");
assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced to v2");
}
fn root_fork_v2(author: &Keys, community: &Community, name: &str, created: u64, genesis_hash: &[u8; 32]) -> (Event, [u8; 32], [u8; 32]) {
let mut meta = crate::community::metadata::CommunityMetadata::of(community);
meta.name = name.into();
let inner = crate::community::roster::build_community_root_edition(author, &community.id, &meta, 2, Some(genesis_hash), created, None).unwrap();
let self_hash = crate::community::version::edition_hash(&community.id.0, 2, Some(genesis_hash), inner.content.as_bytes());
let inner_id = inner.id.to_bytes();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
(outer, self_hash, inner_id)
}
fn channel_fork_v2(author: &Keys, community: &Community, channel_id: &crate::community::ChannelId, name: &str, created: u64, genesis_hash: &[u8; 32]) -> (Event, [u8; 32], [u8; 32]) {
let meta = crate::community::metadata::ChannelMetadata { name: name.into() };
let inner = crate::community::roster::build_channel_metadata_edition(author, channel_id, &meta, 2, Some(genesis_hash), created, None).unwrap();
let self_hash = crate::community::version::edition_hash(&channel_id.0, 2, Some(genesis_hash), inner.content.as_bytes());
let inner_id = inner.id.to_bytes();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
(outer, self_hash, inner_id)
}
#[tokio::test]
async fn channel_same_version_fork_converges_to_the_lower_inner_id() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let channel_id = community.channels[0].id;
let ch_hex = channel_id.to_hex();
let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
let (out_a, ha, ida) = channel_fork_v2(&owner, &community, &channel_id, "alpha", 1000, &genesis_hash);
let (out_b, hb, idb) = channel_fork_v2(&owner, &community, &channel_id, "bravo", 2000, &genesis_hash);
let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
("alpha", ha, ida, "bravo", hb, idb)
} else {
("bravo", hb, idb, "alpha", ha, ida)
};
crate::db::community::set_edition_head_with_id(&cid, &ch_hex, 2, &lose_h, &lose_id).unwrap();
{
let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
c.channels.iter_mut().find(|ch| ch.id == channel_id).unwrap().name = lose_name.into();
crate::db::community::save_community(&c).unwrap();
}
relay.inject(&out_a, &community.relays);
relay.inject(&out_b, &community.relays);
fetch_and_apply_metadata(&relay, &community).await.unwrap();
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
let ch_name = &after.channels.iter().find(|c| c.id == channel_id).unwrap().name;
assert_eq!(ch_name, win_name, "channel converged on the lower-inner-id winner, not our held fork");
assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap(), (2, win_h), "channel head self_hash converged at the SAME version");
assert_eq!(crate::db::community::get_edition_head_inner_id(&cid, &ch_hex).unwrap(), Some(win_id), "channel head inner_id moved to the winner");
fetch_and_apply_metadata(&relay, &community).await.unwrap();
let after2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(&after2.channels.iter().find(|c| c.id == channel_id).unwrap().name, win_name, "no flip back to the higher-id fork");
}
#[tokio::test]
async fn channel_same_version_fork_excludes_an_unauthorized_lower_id_edition() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let channel_id = community.channels[0].id;
let ch_hex = channel_id.to_hex();
let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
let (owner_out, owner_h, owner_id) = channel_fork_v2(&owner, &community, &channel_id, "legit", 1000, &genesis_hash);
let mallory = Keys::generate();
let mal_out = {
let mut chosen = None;
for t in 1..=10_000u64 {
let cand = channel_fork_v2(&mallory, &community, &channel_id, "forged", t, &genesis_hash);
if cand.2 < owner_id { chosen = Some(cand.0); break; }
}
chosen.expect("a mallory channel edition with a lower inner id")
};
relay.inject(&owner_out, &community.relays);
relay.inject(&mal_out, &community.relays);
fetch_and_apply_metadata(&relay, &community).await.unwrap();
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(&after.channels.iter().find(|c| c.id == channel_id).unwrap().name, &"legit".to_string(), "the channel forgery never wins despite a lower inner id");
assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap(), (2, owner_h), "the authorized channel edition is the head");
}
#[tokio::test]
async fn epoch_primary_floor_lets_a_refounding_v1_supersede_a_held_high_version() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
for v in 2..=5u64 {
crate::db::community::set_edition_head_with_id(&cid, &cid, v, &[v as u8; 32], &[v as u8; 32]).unwrap();
}
assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5);
crate::db::community::set_edition_head_with_id(&cid, &cid, 3, &[0x33; 32], &[0x33; 32]).unwrap();
assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5, "in-epoch downgrade refused");
crate::db::community::advance_server_root_epoch(&cid, 1, &[0xEE; 32]).unwrap();
crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0x01; 32], &[0x01; 32]).unwrap();
let (v, h) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
assert_eq!((v, h), (1, [0x01; 32]), "epoch-1 v1 supersedes epoch-0 v5 (epoch-primary)");
assert_eq!(
crate::db::community::get_all_edition_heads_epoched(&cid).unwrap().get(&cid).map(|(e, v, _)| (*e, *v)),
Some((1, 1)),
"head now recorded at epoch 1",
);
crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0xAA; 32], &[0x02; 32]).unwrap();
assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().1, [0x01; 32], "same-epoch same-version is not an advance");
}
#[tokio::test]
async fn same_version_fork_converges_to_the_lower_inner_id() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
("Alpha", ha, ida, "Bravo", hb, idb)
} else {
("Bravo", hb, idb, "Alpha", ha, ida)
};
crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
{
let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
c.name = lose_name.into();
crate::db::community::save_community(&c).unwrap();
}
relay.inject(&out_a, &community.relays);
relay.inject(&out_b, &community.relays);
fetch_and_apply_metadata(&relay, &community).await.unwrap();
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(after.name, win_name, "converged on the lower-inner-id winner, not our own held fork");
assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, win_h), "head self_hash converged at the SAME version");
assert_eq!(crate::db::community::get_edition_head_inner_id(&cid, &cid).unwrap(), Some(win_id), "head inner_id moved to the winner");
fetch_and_apply_metadata(&relay, &community).await.unwrap();
assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().name, win_name, "no flip back to the higher-id fork");
}
#[tokio::test]
async fn converged_head_chains_the_next_edit_without_reforking() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
relay.inject(&out_a, &community.relays);
relay.inject(&out_b, &community.relays);
fetch_and_apply_metadata(&relay, &community).await.unwrap();
assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "converged at v2");
let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
c.name = "Third".into();
republish_community_metadata(&relay, &c).await.unwrap();
assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 3, "advanced to v3 off the converged head");
let empty: std::collections::HashMap<String, (u64, [u8; 32])> = std::collections::HashMap::new();
let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &empty);
assert_eq!(folded.root_head.as_ref().map(|h| h.version), Some(3), "a fresh fold reaches v3");
assert!(!folded.gapped_entities.contains(&community.id.0), "the chain is contiguous genesis -> winner -> v3");
}
#[tokio::test]
async fn same_version_fork_excludes_an_unauthorized_lower_id_edition() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
let (owner_out, owner_h, owner_id) = root_fork_v2(&owner, &community, "Legit", 1000, &genesis_hash);
let mallory = Keys::generate();
let (mal_out, mal_id) = {
let mut chosen = None;
for t in 1..=10_000u64 {
let cand = root_fork_v2(&mallory, &community, "Forged", t, &genesis_hash);
if cand.2 < owner_id { chosen = Some((cand.0, cand.2)); break; }
}
chosen.expect("a mallory edition with a lower inner id")
};
assert!(mal_id < owner_id, "premise: the forgery sorts first author-blind");
relay.inject(&owner_out, &community.relays);
relay.inject(&mal_out, &community.relays);
fetch_and_apply_metadata(&relay, &community).await.unwrap();
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(after.name, "Legit", "the forgery never wins despite a lower inner id");
assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, owner_h), "the authorized edition is the head");
}
#[tokio::test]
async fn same_version_fork_on_an_authority_record_fails_closed() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let bl_eid = crate::community::derive::banlist_locator(&community.id);
let bl_hex = crate::simd::hex::bytes_to_hex_32(&bl_eid);
let prev = [0x99u8; 32]; let build_ban = |list: &[String], created: u64| {
let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, list, 2, Some(&prev), created, None).unwrap();
let self_hash = crate::community::version::edition_hash(&bl_eid, 2, Some(&prev), inner.content.as_bytes());
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
(outer, self_hash, inner.id.to_bytes())
};
let (out_a, ha, ida) = build_ban(&["aa".repeat(32)], 1000);
let (out_b, hb, idb) = build_ban(&["bb".repeat(32)], 2000);
let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
crate::db::community::set_edition_head_with_id(&cid, &bl_hex, 2, &lose_h, &lose_id).unwrap();
relay.inject(&out_a, &community.relays);
relay.inject(&out_b, &community.relays);
let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &floors);
assert!(folded.gapped_entities.contains(&bl_eid), "the authority-record fork is quarantined");
assert!(folded.banlist_head.is_none() && folded.banlist_author.is_none(), "no banlist folded off the withheld view");
}
#[tokio::test]
async fn editions_sign_through_the_active_client_signer() {
let (_tmp, _guard) = init_test_db();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
crate::state::set_nostr_client(nostr_sdk::Client::builder().signer(owner.clone()).build());
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
publish_banlist(&relay, &community, &["dd".repeat(32)]).await.unwrap();
let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
let folded = crate::community::roster::fold_roster(
&fetch_control_inners(&relay, &community).await, &community.id, &floors);
assert_eq!(folded.banlist_author, Some(owner.public_key()), "banlist signed by the client signer");
assert!(folded.root_author.is_some(), "genesis GroupRoot folded");
let _ = crate::state::take_nostr_client();
}
async fn fetch_control_inners(relay: &MemoryRelay, community: &Community) -> Vec<Event> {
let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
let mut out = Vec::new();
for ev in relay.fetch(&query, &community.relays).await.unwrap() {
if let Ok(inner) = crate::community::roster::open_control_edition(&ev, &community.server_root_key) {
out.push(inner);
}
}
out
}
fn simulate_bunker(owner: &Keys) {
crate::state::set_nostr_client(nostr_sdk::Client::builder().signer(owner.clone()).build());
crate::state::MY_SECRET_KEY.clear(&[]);
assert!(crate::state::MY_SECRET_KEY.to_keys().is_none(), "bunker sim: no local key");
}
#[tokio::test]
async fn am_i_banned_detects_own_npub_in_banlist() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let me = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
let cid = community.id.to_hex();
assert!(!am_i_banned(&community), "not banned on a fresh community");
crate::db::community::set_community_banlist(&cid, &[me], 1).unwrap();
assert!(am_i_banned(&community), "own npub in the banlist → banned → self-remove");
crate::db::community::set_community_banlist(&cid, &[], 2).unwrap();
assert!(!am_i_banned(&community), "cleared banlist → not banned");
}
#[tokio::test]
async fn bunker_owner_cannot_ban_in_private_community() {
let (_tmp, _guard) = init_test_db();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
simulate_bunker(&owner);
let victim = "cc".repeat(32);
let err = publish_banlist(&relay, &community, &[victim]).await.unwrap_err();
assert!(err.contains("private community") && err.contains("bunker"), "clear bunker explanation: {err}");
assert!(
crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap().is_empty(),
"the ban must NOT half-apply (nothing published or persisted)"
);
let _ = crate::state::take_nostr_client();
}
#[tokio::test]
async fn bunker_owner_can_ban_in_public_community() {
let (_tmp, _guard) = init_test_db();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
create_public_invite(&relay, &community, None, None).await.unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert!(is_public(&community).unwrap(), "minting a link made it Public");
simulate_bunker(&owner);
let victim = "cc".repeat(32);
publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
assert_eq!(
crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap(),
vec![victim],
"a public ban from a bunker account succeeds (no rekey needed)"
);
let _ = crate::state::take_nostr_client();
}
#[tokio::test]
async fn bunker_owner_cannot_privatize() {
let (_tmp, _guard) = init_test_db();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let (token, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
simulate_bunker(&owner);
let err = revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token)).await.unwrap_err();
assert!(err.contains("private") && err.contains("bunker"), "clear bunker explanation: {err}");
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert!(is_public(&after).unwrap(), "the revoke must NOT half-apply — community stays Public");
let _ = crate::state::take_nostr_client();
}
#[tokio::test]
async fn non_owner_admin_can_edit_community_metadata() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
let admin = Keys::generate();
let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
edited.name = "Admin Renamed".into();
let inner = crate::community::roster::build_community_root_edition(&admin, &community.id, &edited, 2, Some(&genesis_hash), 7000, None).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
fetch_and_apply_metadata(&relay, &community).await.unwrap();
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(after.name, "Admin Renamed", "a MANAGE_METADATA admin (not the owner) can edit metadata");
}
#[tokio::test]
async fn banning_an_admin_revokes_their_role() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
create_public_invite(&relay, &community, None, None).await.unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
let alice = Keys::generate();
let alice_hex = alice.public_key().to_hex();
let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
.grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
assert!(holds_role(&alice_hex), "alice is admin pre-ban");
publish_banlist(&relay, &community, &[alice_hex.clone()]).await.unwrap();
assert!(!holds_role(&alice_hex), "banning an admin revokes their role — no dangling grant");
}
#[tokio::test]
async fn kicking_an_admin_revokes_their_role() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let alice = Keys::generate();
let alice_hex = alice.public_key().to_hex();
let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
.grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
assert!(holds_role(&alice_hex), "alice is admin pre-kick");
publish_kick(&relay, &community, &community.channels[0], &alice_hex).await.unwrap();
assert!(!holds_role(&alice_hex), "kicking an admin revokes their role");
}
#[tokio::test]
async fn republish_channel_metadata_renames_and_publishes() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let channel = community.channels[0].clone();
let ch_hex = channel.id.to_hex();
republish_channel_metadata(&relay, &community, &channel.id, "lobby").await.unwrap();
let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(after.channels[0].name, "lobby", "the producer renamed the channel locally");
assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced");
}
#[tokio::test]
async fn revoking_the_last_link_privatizes_and_rotates_the_base() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
assert!(!is_public(&community).unwrap(), "a fresh community is Private");
assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
let (t1, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
let (t2, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert!(is_public(&c).unwrap(), "minting a link flips the mode to Public");
assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "minting links does NOT rotate the base");
revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t1)).await.unwrap();
let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert!(is_public(&c).unwrap(), "one link remains → still Public");
assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "revoking a non-last link does NOT rotate");
revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert!(!is_public(&c).unwrap(), "revoking the last link flips to Private");
assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "privatize re-founded: the base key rotated");
revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a no-op re-revoke does not double-rotate");
}
#[tokio::test]
async fn private_ban_reseals_base_public_ban_does_not() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let victim = "cc".repeat(32);
assert!(!is_public(&community).unwrap(), "fresh community is Private");
publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a private-community ban re-seals the base");
create_public_invite(&relay, &c, None, None).await.unwrap();
let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert!(is_public(&c).unwrap(), "minted a link → Public");
publish_banlist(&relay, &c, &[victim.clone(), "dd".repeat(32)]).await.unwrap();
let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "a public-community ban does NOT rotate the base");
}
#[tokio::test]
async fn private_ban_seals_the_banned_member_out_of_the_new_root() {
use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
use crate::community::rekey::{open_rekey_event, rekey_pairwise_secret};
use crate::types::Message;
use nostr_sdk::ToBech32;
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let genesis_root = *community.server_root_key.as_bytes();
let channel_hex = community.channels[0].id.to_hex();
let victim = Keys::generate();
let victim_b32 = victim.public_key().to_bech32().unwrap();
let mut m = Message::default();
m.id = "aa".repeat(32);
m.npub = Some(victim_b32.clone());
m.at = 1000;
crate::db::events::save_message(&channel_hex, &m).await.unwrap();
assert!(
crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
"victim is observed before the ban"
);
publish_banlist(&relay, &community, &[victim.public_key().to_hex()]).await.unwrap();
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "private ban re-seals the base");
assert!(
!crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
"the banned victim is no longer observed (banlist hex → bech32 reconciliation worked)"
);
let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
let found = relay
.fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
.await
.unwrap();
assert_eq!(found.len(), 1, "the base rekey is published");
let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
let secret = rekey_pairwise_secret(victim.secret_key(), &parsed.rotator).unwrap();
let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
assert!(
parsed.blobs.iter().all(|b| b.locator != loc),
"the BANNED victim has NO blob — sealed OUT of the new root (read access is actually cut)"
);
}
struct SwapDuringPublishRelay {
inner: MemoryRelay,
}
#[async_trait::async_trait]
impl Transport for SwapDuringPublishRelay {
async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
crate::state::bump_session_generation();
self.inner.publish(event, relays).await
}
async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
crate::state::bump_session_generation();
self.inner.publish_durable(event, relays).await
}
async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
self.inner.fetch(query, relays).await
}
}
#[tokio::test]
async fn account_swap_during_grant_publish_skips_the_local_persist() {
let (_tmp, _guard) = init_test_db();
let setup = MemoryRelay::new();
let community = create_community(&setup, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let member = "cc".repeat(32);
let entity_hex = crate::simd::hex::bytes_to_hex_32(
&crate::community::derive::grant_locator(&community.id, &crate::simd::hex::hex_to_bytes_32(&member)));
assert!(crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_none(), "no grant head yet");
let swap = SwapDuringPublishRelay { inner: MemoryRelay::new() };
set_member_grant(&swap, &community, &member, vec!["a".repeat(64)]).await.unwrap();
assert!(
crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_none(),
"session straddled a swap → persist skipped → no local grant head (account B uncorrupted)"
);
}
#[tokio::test]
async fn account_swap_during_ban_publish_applies_nothing_locally() {
let (_tmp, _guard) = init_test_db();
let setup = MemoryRelay::new();
let community = create_community(&setup, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
assert!(!is_public(&community).unwrap(), "fresh community is Private (a ban would normally re-seal)");
let swap = SwapDuringPublishRelay { inner: MemoryRelay::new() };
publish_banlist(&swap, &community, &["cc".repeat(32)]).await.unwrap();
assert!(crate::db::community::get_community_banlist(&cid).unwrap().is_empty(),
"banlist persist skipped on the stale session");
assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
crate::community::Epoch(0), "no read-cut re-seal → base NOT rotated into the wrong account");
assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(),
"read_cut_pending untouched (need_cut requires is_valid())");
}
#[tokio::test]
async fn swap_session_clears_per_account_state_and_keys() {
let (_tmp, _guard) = init_test_db();
{
let mut st = crate::state::STATE.lock().await;
st.db_loaded = true;
st.is_syncing = true;
}
assert!(crate::state::MY_SECRET_KEY.has_key(), "account A holds a live key");
crate::VectorCore.swap_session().await;
let st = crate::state::STATE.lock().await;
assert!(st.chats.is_empty() && st.profiles.is_empty(), "STATE chats/profiles cleared on swap");
assert!(!st.db_loaded && !st.is_syncing, "db_loaded / is_syncing reset");
assert!(!crate::state::MY_SECRET_KEY.has_key(), "key vault cleared — no leak into account B");
}
#[tokio::test]
async fn join_finalization_persists_and_registers_the_channel() {
let (_tmp, _guard) = init_test_db();
crate::state::STATE.lock().await.chats.clear(); let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
become_local(&Keys::generate());
crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await.unwrap();
assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community persisted on join");
assert!(!crate::state::STATE.lock().await.chats.is_empty(), "the channel is registered as a chat");
}
#[tokio::test]
async fn join_finalization_tears_down_a_banned_joiner() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
create_public_invite(&relay, &community, None, None).await.unwrap();
let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
let joiner = Keys::generate();
publish_banlist(&relay, &community, &[joiner.public_key().to_hex()]).await.unwrap();
become_local(&joiner);
assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community present pre-join");
let result = crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await;
assert!(result.is_err(), "a banned joiner's finalize must fail");
assert!(result.unwrap_err().to_string().contains("banned"), "the error names the ban");
assert!(
crate::db::community::load_community(&community.id).unwrap().is_none(),
"the just-saved community is torn back down — no orphaned row for a banned joiner"
);
}
#[tokio::test]
async fn delete_community_wipes_every_community_scoped_table() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
crate::db::community::store_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[0x11u8; 32]).unwrap();
crate::db::community::save_public_invite("tok", &cid, "https://x/invite#y", None, None).unwrap();
crate::db::community::save_pending_invite(&cid, "{}", "npub1inviter").unwrap();
crate::db::community::set_edition_head(&cid, &cid, 1, &[0x22u8; 32]).unwrap();
crate::db::community::set_community_banlist(&cid, &["cc".repeat(32)], 100).unwrap();
assert!(crate::db::community::community_exists(&community.id).unwrap());
assert!(!crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
assert!(!crate::db::community::list_public_invites(&cid).unwrap().is_empty());
assert!(crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid));
assert!(!crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty());
assert!(!crate::db::community::get_community_banlist(&cid).unwrap().is_empty());
crate::db::community::delete_community(&cid).unwrap();
assert!(!crate::db::community::community_exists(&community.id).unwrap(), "communities row gone");
assert!(crate::db::community::load_community(&community.id).unwrap().is_none(), "community not loadable");
assert!(crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys wiped");
assert!(crate::db::community::list_public_invites(&cid).unwrap().is_empty(), "public invites wiped");
assert!(!crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid), "pending invites wiped");
assert!(crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty(), "edition heads wiped");
assert!(crate::db::community::get_community_banlist(&cid).unwrap().is_empty(), "banlist wiped with the channels");
}
#[tokio::test]
async fn fetch_control_folded_skips_junk_injected_at_the_coordinate() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let owner_hex = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
let junk = nostr_sdk::EventBuilder::new(nostr_sdk::Kind::Custom(event_kind::COMMUNITY_CONTROL), "not a sealed edition")
.tags([nostr_sdk::Tag::custom(nostr_sdk::TagKind::Custom("z".into()), [z])])
.sign_with_keys(&Keys::generate())
.unwrap();
relay.publish(&junk, &community.relays).await.unwrap();
let folded = fetch_control_folded(&relay, &community).await.unwrap();
assert!(
!crate::community::roster::authorize_delegation(&folded, Some(&owner_hex)).roles.is_empty(),
"the genuine Admin role still folds; the un-openable junk is silently dropped"
);
}
#[tokio::test]
async fn fetch_control_folded_on_dead_relays_is_empty_not_a_panic() {
let (_tmp, _guard) = init_test_db();
let community = saved_community_owned_by(&Keys::generate());
let folded = fetch_control_folded(&FailingRelay, &community).await.unwrap();
assert!(folded.roles.roles.is_empty() && folded.root_meta.is_none(), "dead relays → empty fold, no panic");
}
#[tokio::test]
async fn successful_private_ban_leaves_no_read_cut_pending() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
publish_banlist(&relay, &community, &["cc".repeat(32)]).await.unwrap();
assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "a successful re-seal leaves no pending read-cut");
let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(c.server_root_epoch, crate::community::Epoch(1));
}
#[tokio::test]
async fn failed_reseal_sets_pending_then_sync_retry_recovers() {
let (_tmp, _guard) = init_test_db();
let relay = RekeyFailingRelay::new(); let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let victim = "cc".repeat(32);
assert!(publish_banlist(&relay, &community, &[victim.clone()]).await.is_err(), "the re-seal's base rekey fails");
assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "a failed re-seal leaves read_cut_pending set");
assert_eq!(crate::db::community::get_community_banlist(&cid).unwrap(), vec![victim.clone()], "the ban itself still applied");
let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "base NOT rotated while the re-seal is pending");
relay.allow_rekey();
retry_pending_read_cut(&relay, &c).await.unwrap();
assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the retry succeeds");
let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "the read-cut finally rotated the base");
}
#[tokio::test]
async fn privatize_reseals_to_observed_participants_not_just_owner() {
use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
use crate::types::Message;
use nostr_sdk::ToBech32;
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let genesis_root = *community.server_root_key.as_bytes();
let channel_hex = community.channels[0].id.to_hex();
let alice = Keys::generate();
let alice_b32 = alice.public_key().to_bech32().unwrap();
let mut m = Message::default();
m.id = "aa".repeat(32);
m.npub = Some(alice_b32.clone());
m.at = 1000;
crate::db::events::save_message(&channel_hex, &m).await.unwrap();
assert!(
crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &alice_b32),
"alice is an observed participant"
);
let (token_hex, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token_hex)).await.unwrap();
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "privatize rotated the base");
let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
let found = relay
.fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
.await
.unwrap();
assert_eq!(found.len(), 1, "the base rekey is published");
let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
let secret = rekey_pairwise_secret(alice.secret_key(), &parsed.rotator).unwrap();
let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
let alice_blob = parsed.blobs.iter().find(|b| b.locator == loc).expect("alice's blob present (NOT sealed out)");
let recovered = open_rekey_blob(alice.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, alice_blob).unwrap();
assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "alice recovers the new root = owner's advanced base");
}
#[tokio::test]
async fn unpermissioned_invite_links_edition_is_rejected() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let mallory = Keys::generate();
let loc = "2b".repeat(32);
let inner = crate::community::roster::build_invite_links_edition(&mallory, &community.id, &[loc], 1, None, 1000, None).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
assert!(applied.is_empty(), "an unpermissioned member's link edition is rejected");
assert!(!is_public(&community).unwrap(), "mode stays Private despite the forged edition");
}
#[tokio::test]
async fn invite_links_union_across_authorized_creators() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
create_public_invite(&relay, &community, None, None).await.unwrap();
let owner_loc = public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(
&crate::db::community::list_public_invites(&cid).unwrap()[0].token));
let admin = Keys::generate();
let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
let admin_loc = "ab".repeat(32);
let inner = crate::community::roster::build_invite_links_edition(&admin, &community.id, &[admin_loc.clone()], 1, None, 2000, None).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
relay.inject(&outer, &community.relays);
let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
assert!(agg.contains(&owner_loc), "owner's link in the aggregate");
assert!(agg.contains(&admin_loc), "the granted admin's link unions in too");
assert!(is_public(&community).unwrap());
let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
let owner_token = crate::db::community::list_public_invites(&cid).unwrap()[0].token.clone();
revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&owner_token)).await.unwrap();
let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "another creator's link remains → no privatize rekey");
assert!(is_public(&c).unwrap(), "still Public (admin's link is live)");
}
#[tokio::test]
async fn failed_banlist_publish_does_not_persist_locally() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let id_hex = community.id.to_hex();
assert!(crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty());
let victim = "cc".repeat(32);
let err = publish_banlist(&FailingRelay, &community, &[victim]).await;
assert!(err.is_err(), "a failed publish must propagate");
assert!(
crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty(),
"local banlist must be untouched when the publish failed"
);
}
#[tokio::test]
async fn metadata_failed_publish_does_not_persist_locally() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
community.name = "Renamed HQ".to_string();
assert!(republish_community_metadata(&FailingRelay, &community).await.is_err());
let loaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(loaded.name, "HQ", "a failed metadata publish leaves the local name unchanged");
}
#[tokio::test]
async fn send_persists_key_then_delete_round_trip() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = Community::create("HQ", "general", vec!["r1".into()]);
let channel = community.channels[0].clone();
let alice = Keys::generate();
let _outer = send_message(&relay, &community, &channel, &alice, "deletable", 1).await.unwrap();
let before = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
assert_eq!(before.len(), 1);
let message_id = before[0].message_id.to_hex();
delete_message(&relay, &message_id).await.unwrap();
let after = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
assert!(after.is_empty(), "message should be deleted after delete_message");
assert!(delete_message(&relay, &message_id).await.is_err());
}
#[tokio::test]
async fn failed_delete_publish_preserves_key() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = Community::create("HQ", "general", vec!["r1".into()]);
let channel = community.channels[0].clone();
let alice = Keys::generate();
send_message(&relay, &community, &channel, &alice, "delete me", 1).await.unwrap();
let message_id = fetch_channel_messages(&relay, &community, &channel).await.unwrap()[0]
.message_id
.to_hex();
assert!(delete_message(&FailingRelay, &message_id).await.is_err());
delete_message(&relay, &message_id).await.unwrap();
assert!(fetch_channel_messages(&relay, &community, &channel).await.unwrap().is_empty());
}
#[tokio::test]
async fn delete_unknown_message_errors() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let fake = Keys::generate();
let bogus = EventBuilder::new(Kind::Custom(1), "x").sign_with_keys(&fake).unwrap().id;
assert!(delete_message(&relay, &bogus.to_hex()).await.is_err());
}
#[tokio::test]
async fn accept_invite_persists_member_view() {
let (_tmp, _guard) = init_test_db();
let owner = Community::create("HQ", "general", vec!["r1".into()]);
let invite = crate::community::invite::build_invite(&owner);
let joined = accept_invite(&invite).expect("accept");
assert!(!is_proven_owner(&joined), "joined as member, not owner");
let loaded = crate::db::community::load_community(&owner.id).unwrap().expect("saved");
assert_eq!(loaded.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
}
#[tokio::test]
async fn accept_invite_does_not_downgrade_owned_community() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let owner = create_community(&relay, "HQ", "general", vec![]).await.unwrap();
assert!(is_proven_owner(&owner), "we are the proven owner");
let invite = crate::community::invite::build_invite(&owner);
let err = accept_invite(&invite).unwrap_err();
assert!(err.contains("already own"), "must refuse to downgrade an owned community, got: {err}");
let reloaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
assert_eq!(reloaded.server_root_key.as_bytes(), owner.server_root_key.as_bytes());
}
#[tokio::test]
async fn accept_invite_rejects_id_collision_under_different_authority() {
let (_tmp, _guard) = init_test_db();
let legit = Community::create("X", "general", vec!["wss://legit".into()]);
let member_x = accept_invite(&crate::community::invite::build_invite(&legit)).unwrap();
let original_key = member_x.channels[0].key.as_bytes().to_vec();
let attacker = Community::create("evil", "general", vec!["wss://evil".into()]);
let mut hostile = crate::community::invite::build_invite(&attacker);
hostile.community_id = legit.id.to_hex();
assert_ne!(hostile.server_root_key, crate::simd::hex::bytes_to_hex_32(member_x.server_root_key.as_bytes()));
assert!(accept_invite(&hostile).is_err(), "id-collision under new authority must be rejected");
let reloaded = crate::db::community::load_community(&legit.id).unwrap().unwrap();
assert_eq!(reloaded.channels[0].key.as_bytes().to_vec(), original_key);
assert_eq!(reloaded.relays, vec!["wss://legit".to_string()]);
}
#[tokio::test]
async fn rejected_accept_leaves_pending_invite_intact() {
let (_tmp, _guard) = init_test_db();
let owner = attested_community("HQ", "general", vec![]);
crate::db::community::save_community(&owner).unwrap();
let bundle = crate::community::invite::build_invite(&owner).to_json().unwrap();
let cid = owner.id.to_hex();
crate::db::community::save_pending_invite(&cid, &bundle, "npub1inviter").unwrap();
let peeked = crate::db::community::get_pending_invite(&cid).unwrap().expect("parked");
let invite = crate::community::invite::CommunityInvite::from_json(&peeked).unwrap();
assert!(accept_invite(&invite).is_err(), "owning the id → reject");
assert!(
crate::db::community::pending_invite_exists(&cid).unwrap(),
"rejected accept must leave the invite parked"
);
let other = Community::create("Other", "general", vec![]);
let ob = crate::community::invite::build_invite(&other).to_json().unwrap();
let ocid = other.id.to_hex();
crate::db::community::save_pending_invite(&ocid, &ob, "npub1inviter").unwrap();
let op = crate::db::community::get_pending_invite(&ocid).unwrap().unwrap();
let oinvite = crate::community::invite::CommunityInvite::from_json(&op).unwrap();
accept_invite(&oinvite).expect("accept ok");
crate::db::community::delete_pending_invite(&ocid).unwrap();
assert!(!crate::db::community::pending_invite_exists(&ocid).unwrap(), "cleared on success");
}
#[tokio::test]
async fn public_invite_create_fetch_accept_revoke_round_trip() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let mut owner = Community::create("Public HQ", "general", vec!["r1".into(), "r2".into()]);
owner.description = Some("everyone welcome".into());
let owner_keys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
owner.owner_attestation = Some(
crate::community::owner::build_owner_attestation_unsigned(owner_keys.public_key(), &owner.id.to_hex())
.sign_with_keys(&owner_keys).unwrap().as_json(),
);
let (token_hex, url) = create_public_invite(&relay, &owner, None, None).await.expect("mint");
assert!(url.contains('#'));
assert_eq!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().len(), 1);
let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
assert_eq!(crate::simd::hex::bytes_to_hex_32(&token), token_hex);
let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("fetch");
assert_eq!(bundle.preview.name, "Public HQ");
assert_eq!(bundle.preview.description.as_deref(), Some("everyone welcome"));
let joined = accept_public_invite(&bundle, 0).expect("accept");
assert_eq!(joined.id, owner.id);
assert_eq!(joined.description.as_deref(), Some("everyone welcome"), "preview patched in");
revoke_public_invite(&relay, &owner, &token).await.expect("revoke");
assert!(fetch_public_invite(&relay, &relays, &token).await.is_err(), "revoked link is dead");
assert!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().is_empty());
}
#[tokio::test]
async fn revoked_invite_dies_even_if_one_relay_kept_the_bundle() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let owner = attested_community("HQ", "general", vec!["r1".into(), "r2".into()]);
let (_token_hex, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
assert!(fetch_public_invite(&relay, &relays, &token).await.is_ok(), "live on both relays");
let tombstone = public_invite::build_public_invite_tombstone(&token).unwrap();
relay.inject(&tombstone, &["r1".to_string()]);
assert!(
fetch_public_invite(&relay, &relays, &token).await.is_err(),
"a tombstone on any one relay kills the link, even with a stale live bundle elsewhere",
);
}
#[tokio::test]
async fn fetch_skips_relay_shadow_junk_to_genuine_bundle() {
use nostr_sdk::prelude::{EventBuilder, Keys, Kind, Tag, TagKind, Timestamp};
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let owner = attested_community("HQ", "general", vec!["r1".into()]);
let (_t, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
let attacker = Keys::generate();
let junk = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "garbage")
.tags([
Tag::identifier(public_invite::locator_hex(&token)),
Tag::custom(TagKind::Custom("vsk".into()), ["6".to_string()]),
Tag::custom(TagKind::Custom("v".into()), ["1".to_string()]),
])
.custom_created_at(Timestamp::from_secs(9_000_000_000))
.sign_with_keys(&attacker)
.unwrap();
relay.publish(&junk, &relays).await.unwrap();
let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("genuine survives shadow");
assert_eq!(bundle.preview.name, "HQ");
}
#[tokio::test]
async fn expired_public_invite_is_refused() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let owner = attested_community("HQ", "general", vec!["r1".into()]);
let (_t, url) = create_public_invite(&relay, &owner, Some(1000), None).await.unwrap();
let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
let bundle = fetch_public_invite(&relay, &relays, &token).await.unwrap();
assert!(accept_public_invite(&bundle, 2000).is_err());
assert!(crate::db::community::load_community(&owner.id).unwrap().is_none());
}
#[tokio::test]
async fn republish_metadata_saves_and_publishes() {
use crate::community::CommunityImage;
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let mut owner = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = owner.id.to_hex();
owner.name = "HQ Renamed".into();
owner.description = Some("now with topic".into());
owner.icon = Some(CommunityImage {
url: "https://b/x".into(), key: "aa".repeat(32), nonce: "bb".repeat(12),
hash: "cc".repeat(32), ext: "png".into(),
});
republish_community_metadata(&relay, &owner).await.expect("republish");
let loaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
assert_eq!(loaded.name, "HQ Renamed");
assert_eq!(loaded.description.as_deref(), Some("now with topic"));
assert_eq!(loaded.icon.unwrap().url, "https://b/x");
let (head_v, _) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
assert_eq!(head_v, 2, "GroupRoot edition advanced v1 (create) → v2 (republish)");
let z = crate::community::roster::control_pseudonym(&owner.server_root_key, &owner.id, crate::community::Epoch(0));
let control = relay
.fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &owner.relays)
.await
.unwrap();
let newest = control
.iter()
.filter_map(|o| crate::community::roster::open_control_edition(o, &owner.server_root_key).ok())
.filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
.filter(|p| p.entity_id == owner.id.0)
.max_by_key(|p| p.version)
.expect("GroupRoot edition on the relay");
let meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&newest.content).unwrap();
assert_eq!(meta.name, "HQ Renamed");
assert_eq!(meta.icon.unwrap().ext, "png");
}
#[tokio::test]
async fn member_cannot_republish_metadata() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let owner = Community::create("HQ", "general", vec!["r1".into()]);
let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
assert!(republish_community_metadata(&relay, &member).await.is_err());
}
#[tokio::test]
async fn member_cannot_mint_public_invite() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let owner = Community::create("HQ", "general", vec!["r1".into()]);
let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
assert!(create_public_invite(&relay, &member, None, None).await.is_err(), "members can't mint links");
}
#[tokio::test]
async fn accept_oversized_bundle_rejected() {
let (_tmp, _guard) = init_test_db();
let owner = Community::create("HQ", "general", vec![]);
let mut invite = crate::community::invite::build_invite(&owner);
let template = invite.channels[0].clone();
for _ in 0..300 {
invite.channels.push(template.clone());
}
assert!(accept_invite(&invite).is_err(), "oversized bundle must be rejected");
assert!(crate::db::community::load_community(&owner.id).unwrap().is_none(), "nothing persisted");
}
async fn publish_tombstone<T: Transport + ?Sized>(transport: &T, community: &Community, author: &Keys, created_at: u64) {
let inner = crate::community::roster::build_group_dissolved_edition_unsigned(author.public_key(), &community.id, created_at)
.sign_with_keys(author).unwrap();
let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch).unwrap();
transport.publish_durable(&outer, &community.relays).await.unwrap();
}
struct RekeyCountingRelay {
inner: MemoryRelay,
rekeys: std::sync::atomic::AtomicUsize,
}
impl RekeyCountingRelay {
fn new() -> Self { Self { inner: MemoryRelay::new(), rekeys: std::sync::atomic::AtomicUsize::new(0) } }
fn count(&self, e: &Event) {
if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
}
#[async_trait::async_trait]
impl Transport for RekeyCountingRelay {
async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish(e, r).await }
async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish_durable(e, r).await }
async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
}
#[tokio::test]
async fn owner_tombstone_folds_to_dissolved() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
publish_tombstone(&relay, &community, &owner, 1000).await;
assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "alive before the fold");
fetch_and_apply_control(&relay, &community).await.unwrap();
assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "owner tombstone seals the community");
}
#[tokio::test]
async fn non_owner_tombstone_is_ignored() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let mallory = Keys::generate();
publish_tombstone(&relay, &community, &mallory, 1000).await;
fetch_and_apply_control(&relay, &community).await.unwrap();
assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "a non-owner tombstone is ignored");
}
#[tokio::test]
async fn unreadable_deed_rejects_the_tombstone() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
publish_tombstone(&relay, &community, &owner, 1000).await;
community.owner_attestation = None;
crate::db::community::save_community(&community).unwrap();
let stripped = crate::db::community::load_community(&community.id).unwrap().unwrap();
fetch_and_apply_control(&relay, &stripped).await.unwrap();
assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "unverifiable tombstone is rejected, not death-by-default");
}
#[tokio::test]
async fn binary_seal_drops_every_subsequent_event_with_no_timestamp_test() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
publish_tombstone(&relay, &community, &owner, 1000).await;
fetch_and_apply_control(&relay, &community).await.unwrap();
assert!(crate::db::community::get_community_dissolved(&cid).unwrap());
let sealed = crate::db::community::load_community(&community.id).unwrap().unwrap();
let channel = sealed.channels[0].clone();
let me = owner.public_key();
let backdated = super::super::envelope::seal_message(
&Keys::generate(), &channel.key, &channel.id, channel.epoch, "ghost", 1,
).unwrap();
let mut state = crate::state::ChatState::new();
assert!(super::super::inbound::process_incoming(&mut state, &backdated, &channel, &me).is_none(),
"a backdated message after the seal is dropped (binary seal, no timestamp test)");
publish_tombstone(&relay, &sealed, &owner, 2000).await;
assert_eq!(fetch_and_apply_control(&relay, &sealed).await.unwrap(), 0,
"control fold stops advancing once sealed");
}
#[tokio::test]
async fn dissolve_community_emits_no_rekey_and_no_epoch_bump() {
let (_tmp, _guard) = init_test_db();
let relay = RekeyCountingRelay::new();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
create_public_invite(&relay, &community, None, None).await.unwrap();
let before_epoch = crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch;
dissolve_community(&relay, &community).await.unwrap();
assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "sealed locally");
assert_eq!(relay.rekeys.load(std::sync::atomic::Ordering::Relaxed), 0,
"dissolution publishes NO 3303 rekey (no last-link privatize re-founding)");
assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch, before_epoch,
"base epoch unchanged — dissolution rotates nothing");
}
#[tokio::test]
async fn duplicate_owner_tombstones_are_idempotent() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
publish_tombstone(&relay, &community, &owner, 1000).await;
publish_tombstone(&relay, &community, &owner, 2000).await;
fetch_and_apply_control(&relay, &community).await.unwrap();
assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "duplicates still just dissolve, no error");
assert_eq!(fetch_and_apply_control(&relay, &community).await.unwrap(), 0);
}
#[test]
fn apply_server_root_rekey_refuses_once_dissolved() {
let (_tmp, _guard) = init_test_db();
let owner = Keys::generate();
let me = Keys::generate();
become_local(&me);
let community = saved_community_owned_by(&owner);
let cid = community.id.to_hex();
crate::db::community::set_community_dissolved(&cid).unwrap();
let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &[0xCDu8; 32]);
assert!(apply_server_root_rekey(&community, &parsed).is_err(),
"a base rekey cannot cross a tombstone");
assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
crate::community::Epoch(0), "base epoch did not advance");
}
#[tokio::test]
async fn tombstone_detected_after_a_base_rotation() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
publish_tombstone(&relay, &rotated, &owner, 1000).await;
fetch_and_apply_control(&relay, &rotated).await.unwrap();
assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
"tombstone at the rotation-stable locator is detected post-rotation");
}
#[tokio::test]
async fn stable_coordinate_tombstone_survives_a_concurrent_rotation() {
let (_tmp, _guard) = init_test_db();
let relay = MemoryRelay::new();
let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
let cid = community.id.to_hex();
let inner = crate::community::roster::build_group_dissolved_edition_unsigned(owner.public_key(), &community.id, 1000)
.sign_with_keys(&owner).unwrap();
let stable = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id).unwrap();
relay.inject(&stable, &community.relays);
rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "not folded yet");
fetch_and_apply_control(&relay, &rotated).await.unwrap();
assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
"stable-coordinate probe discovers the tombstone cross-epoch (C3 closed)");
}
}