1use nostr_sdk::prelude::{Event, EventId, JsonUtil, Keys, Tag, ToBech32};
11
12use super::invite::CommunityInvite;
13use super::public_invite::{
14 self, build_public_invite_event, locator_hex, parse_public_invite_event, PublicInviteBundle,
15};
16use super::send::{delete_own_message, publish_signed_message};
17use super::transport::{Query, Transport};
18use super::{Channel, Community};
19use crate::state::SessionGuard;
20use crate::stored_event::event_kind;
21
22async fn active_signer() -> Result<std::sync::Arc<dyn nostr_sdk::prelude::NostrSigner>, String> {
29 if let Some(client) = crate::state::nostr_client() {
30 if let Ok(s) = client.signer().await {
31 return Ok(s);
32 }
33 }
34 let keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no signer available (no client and no local key)")?;
35 Ok(std::sync::Arc::new(keys))
36}
37
38pub async fn create_community<T: Transport + ?Sized>(
43 transport: &T,
44 name: &str,
45 default_channel_name: &str,
46 relays: Vec<String>,
47) -> Result<Community, String> {
48 let session = SessionGuard::capture();
49 let mut community = Community::create(name, default_channel_name, relays);
50 let owner_pk = crate::state::my_public_key().ok_or("cannot create a community without an identity")?;
56 let unsigned = super::owner::build_owner_attestation_unsigned(owner_pk, &community.id.to_hex());
57 let attestation = if let Some(keys) = crate::state::MY_SECRET_KEY.to_keys().filter(|k| k.public_key() == owner_pk) {
61 unsigned.sign_with_keys(&keys).map_err(|e| format!("sign owner attestation: {e}"))?
62 } else if let Some(client) = crate::state::nostr_client() {
63 let signer = client.signer().await.map_err(|e| format!("no signer for owner attestation: {e}"))?;
64 unsigned.sign(&signer).await.map_err(|e| format!("sign owner attestation: {e}"))?
65 } else {
66 return Err("cannot create a community without an identity signer (the owner attestation is mandatory)".to_string());
67 };
68 community.owner_attestation = Some(attestation.as_json());
69 if !session.is_valid() {
71 return Err("account changed during community creation".to_string());
72 }
73 crate::db::community::save_community(&community)?;
79
80 let signer = active_signer().await?;
83 let cid = community.id.to_hex();
84 let created = std::time::SystemTime::now()
85 .duration_since(std::time::UNIX_EPOCH)
86 .map(|d| d.as_secs())
87 .unwrap_or(0);
88
89 let admin = super::roles::Role::admin(crate::simd::hex::bytes_to_hex_32(&super::random_32()));
97 let root_meta = super::metadata::CommunityMetadata::of(&community);
98 let root_inner = super::roster::build_community_root_edition_unsigned(owner_pk, &community.id, &root_meta, 1, None, created, None)?
99 .sign(&signer).await.map_err(|e| format!("sign genesis group-root: {e}"))?;
100 let role_inner = super::roster::build_role_edition_unsigned(owner_pk, &admin, 1, None, created, None)?
101 .sign(&signer).await.map_err(|e| format!("sign genesis admin-role: {e}"))?;
102 let mut heads: Vec<(String, [u8; 32], Option<[u8; 32]>)> = vec![
106 (cid.clone(), super::version::edition_hash(&community.id.0, 1, None, root_inner.content.as_bytes()), Some(root_inner.id.to_bytes())),
107 (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),
108 ];
109 let mut to_publish: Vec<Event> = vec![
110 super::roster::seal_control_edition(&Keys::generate(), &root_inner, &community.server_root_key, &community.id, community.server_root_epoch)?,
111 super::roster::seal_control_edition(&Keys::generate(), &role_inner, &community.server_root_key, &community.id, community.server_root_epoch)?,
112 ];
113 for channel in &community.channels {
114 let meta = super::metadata::ChannelMetadata { name: channel.name.clone() };
115 let inner = super::roster::build_channel_metadata_edition_unsigned(owner_pk, &channel.id, &meta, 1, None, created, None)?
116 .sign(&signer).await.map_err(|e| format!("sign genesis channel-metadata: {e}"))?;
117 heads.push((channel.id.to_hex(), super::version::edition_hash(&channel.id.0, 1, None, inner.content.as_bytes()), Some(inner.id.to_bytes())));
118 to_publish.push(super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?);
119 }
120 for outer in &to_publish {
124 transport.publish_durable(outer, &community.relays).await?;
125 }
126 if session.is_valid() {
129 for (entity_hex, hash, inner_id) in &heads {
130 let _ = match inner_id {
131 Some(id) => crate::db::community::set_edition_head_with_id(&cid, entity_hex, 1, hash, id),
132 None => crate::db::community::set_edition_head(&cid, entity_hex, 1, hash),
133 };
134 }
135 let roster = super::roles::CommunityRoles { roles: vec![admin], grants: Vec::new() };
136 let _ = crate::db::community::set_community_roles(&cid, &roster, created as i64);
137 }
138 Ok(community)
139}
140
141pub async fn send_message<T: Transport + ?Sized>(
144 transport: &T,
145 community: &Community,
146 channel: &Channel,
147 author: &Keys,
148 content: &str,
149 ms: u64,
150) -> Result<Event, String> {
151 let session = SessionGuard::capture();
152 let inner = super::envelope::build_inner_event(author.public_key(), &channel.id, channel.epoch, content, ms, None)
156 .sign_with_keys(author)
157 .map_err(|e| e.to_string())?;
158 let (outer, ephemeral) = publish_signed_message(transport, community, channel, &inner, false).await?;
159 if !session.is_valid() {
162 return Err("account changed during send; not persisting message key".to_string());
163 }
164 crate::db::community::store_message_key(&inner.id.to_hex(), &outer.id.to_hex(), &ephemeral, &community.relays)?;
165 Ok(outer)
166}
167
168pub async fn send_signed_message<T: Transport + ?Sized>(
173 transport: &T,
174 community: &Community,
175 channel: &Channel,
176 inner: &Event,
177) -> Result<Event, String> {
178 let session = SessionGuard::capture();
179 let (outer, ephemeral) = publish_signed_message(transport, community, channel, inner, false).await?;
180 if !session.is_valid() {
181 return Err("account changed during send; not persisting message key".to_string());
182 }
183 crate::db::community::store_message_key(&inner.id.to_hex(), &outer.id.to_hex(), &ephemeral, &community.relays)?;
184 Ok(outer)
185}
186
187pub async fn build_presence(
197 channel: &Channel,
198 joined: bool,
199 attribution: Option<(String, Option<String>)>,
200) -> Result<nostr_sdk::Event, String> {
201 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
202 let ms = std::time::SystemTime::now()
203 .duration_since(std::time::UNIX_EPOCH)
204 .map(|d| d.as_millis() as u64)
205 .unwrap_or(0);
206 let content = match (joined, attribution) {
207 (false, _) => "leave".to_string(),
208 (true, Some((by, label))) => serde_json::json!({ "by": by, "l": label }).to_string(),
209 (true, None) => "join".to_string(),
210 };
211 let unsigned = super::envelope::build_inner_typed(
212 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, &content, ms, None, &[],
213 );
214 let signer = active_signer().await?;
215 unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign presence: {e}"))
216}
217
218pub async fn publish_presence_event<T: Transport + ?Sized>(
220 transport: &T,
221 community: &Community,
222 channel: &Channel,
223 inner: &nostr_sdk::Event,
224) -> Result<(), String> {
225 let _ = publish_signed_message(transport, community, channel, inner, true).await?;
226 Ok(())
227}
228
229pub async fn publish_presence<T: Transport + ?Sized>(
230 transport: &T,
231 community: &Community,
232 channel: &Channel,
233 joined: bool,
234 attribution: Option<(String, Option<String>)>,
235) -> Result<(), String> {
236 let inner = build_presence(channel, joined, attribution).await?;
237 publish_presence_event(transport, community, channel, &inner).await
238}
239
240pub async fn publish_webxdc_signal<T: Transport + ?Sized>(
247 transport: &T,
248 community: &Community,
249 channel: &Channel,
250 topic_id: &str,
251 node_addr: Option<&str>,
252) -> Result<(), String> {
253 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
254 let ms = std::time::SystemTime::now()
255 .duration_since(std::time::UNIX_EPOCH)
256 .map(|d| d.as_millis() as u64)
257 .unwrap_or(0);
258 let content = match node_addr {
259 Some(addr) => serde_json::json!({ "op": "ad", "topic": topic_id, "addr": addr }).to_string(),
260 None => serde_json::json!({ "op": "left", "topic": topic_id }).to_string(),
261 };
262 let unsigned = super::envelope::build_inner_typed(
263 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_WEBXDC, &content, ms, None, &[],
264 );
265 let signer = active_signer().await?;
266 let inner = unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign webxdc signal: {e}"))?;
267 let _ = publish_signed_message(transport, community, channel, &inner, true).await?;
268 Ok(())
269}
270
271pub async fn publish_typing_signal<T: Transport + ?Sized>(
277 transport: &T,
278 community: &Community,
279 channel: &Channel,
280) -> Result<(), String> {
281 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
282 let ms = std::time::SystemTime::now()
283 .duration_since(std::time::UNIX_EPOCH)
284 .map(|d| d.as_millis() as u64)
285 .unwrap_or(0);
286 let unsigned = super::envelope::build_inner_typed(
287 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_TYPING, "typing", ms, None, &[],
288 );
289 let signer = active_signer().await?;
290 let inner = unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign typing signal: {e}"))?;
291 let _ = publish_signed_message(transport, community, channel, &inner, false).await?;
292 Ok(())
293}
294
295pub async fn persist_webxdc_signal(
302 channel_hex: &str,
303 npub: &str,
304 topic_id: &str,
305 node_addr: Option<&str>,
306 event_id: &str,
307 created_at: u64,
308) {
309 if crate::db::events::event_exists(event_id).unwrap_or(true) {
310 return;
311 }
312 let now_secs = std::time::SystemTime::now()
315 .duration_since(std::time::UNIX_EPOCH)
316 .unwrap_or_default()
317 .as_secs();
318 let created_at = created_at.min(now_secs + 300);
319 let Ok(chat_id) = crate::db::id_cache::get_or_create_chat_id(channel_hex) else { return };
320 let mut tags = vec![
321 vec!["webxdc-topic".to_string(), topic_id.to_string()],
322 vec!["d".to_string(), "vector-webxdc-peer".to_string()],
323 ];
324 if let Some(addr) = node_addr {
325 tags.push(vec!["webxdc-node-addr".to_string(), addr.to_string()]);
326 }
327 let event = crate::stored_event::StoredEvent {
328 id: event_id.to_string(),
329 kind: crate::stored_event::event_kind::APPLICATION_SPECIFIC,
330 chat_id,
331 user_id: None,
332 content: if node_addr.is_some() { "peer-advertisement" } else { "peer-left" }.to_string(),
333 tags,
334 reference_id: Some(topic_id.to_string()),
335 created_at,
336 received_at: std::time::SystemTime::now()
337 .duration_since(std::time::UNIX_EPOCH)
338 .unwrap_or_default()
339 .as_millis() as u64,
340 mine: false,
341 pending: false,
342 failed: false,
343 wrapper_event_id: None,
344 npub: Some(npub.to_string()),
345 preview_metadata: None,
346 };
347 if let Err(e) = crate::db::events::save_event(&event).await {
348 crate::log_warn!("[community] failed to persist webxdc peer signal: {e}");
349 }
350}
351
352async fn strip_member_roles_on_removal<T: Transport + ?Sized>(
366 transport: &T,
367 community: &Community,
368 member_hex: &str,
369) {
370 let cid = community.id.to_hex();
371 let roster = match crate::db::community::get_community_roles(&cid) {
372 Ok(r) => r,
373 Err(_) => return,
374 };
375 let held: Vec<String> = roster
376 .grants
377 .iter()
378 .find(|g| g.member == member_hex)
379 .map(|g| g.role_ids.clone())
380 .unwrap_or_default();
381 if held.is_empty() {
382 return; }
384 for role_id in &held {
385 if caller_can_manage_role(community, &roster, role_id, member_hex).is_err() {
386 crate::log_warn!(
387 "removal: not authorized to revoke role {role_id} of {member_hex}; leaving the grant (kick/ban still neutralizes)"
388 );
389 return;
390 }
391 }
392 if let Err(e) = set_member_grant(transport, community, member_hex, Vec::new()).await {
393 crate::log_warn!("removal: role-strip publish failed for {member_hex}: {e}");
394 }
395}
396
397pub async fn publish_kick<T: Transport + ?Sized>(
398 transport: &T,
399 community: &Community,
400 channel: &Channel,
401 target_hex: &str,
402) -> Result<String, String> {
403 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
404 let me = author_pk.to_hex();
405 let cid = community.id.to_hex();
406 {
409 let owner = proven_owner_hex(community);
410 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
411 if !roster.can_act_on_member(&me, owner.as_deref(), target_hex, super::roles::Permissions::KICK) {
412 return Err("you can't kick a member who outranks you (or the owner)".to_string());
413 }
414 }
415 let ms = std::time::SystemTime::now()
416 .duration_since(std::time::UNIX_EPOCH)
417 .map(|d| d.as_millis() as u64)
418 .unwrap_or(0);
419 let citation = authority_citation(community, &me);
421 let extra: Vec<nostr_sdk::prelude::Tag> = citation.iter().map(|c| c.to_tag()).collect();
422 let unsigned = super::envelope::build_inner_full(
423 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_KICK, target_hex, ms, None, &[], &extra,
424 );
425 let signer = active_signer().await?;
426 let inner = unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign kick: {e}"))?;
427 publish_signed_message(transport, community, channel, &inner, true).await?;
428 strip_member_roles_on_removal(transport, community, target_hex).await;
431 Ok(inner.id.to_hex())
433}
434
435
436pub async fn publish_banlist<T: Transport + ?Sized>(
442 transport: &T,
443 community: &Community,
444 banned_hex: &[String],
445) -> Result<(), String> {
446 let session = SessionGuard::capture();
447 let cid = community.id.to_hex();
448 let signer = active_signer().await?;
451 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the banlist edition")?;
452 {
457 let me = actor_pk.to_hex();
458 let owner = proven_owner_hex(community);
459 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
460 let current: std::collections::HashSet<String> =
461 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
462 let next: std::collections::HashSet<&str> = banned_hex.iter().map(|s| s.as_str()).collect();
463 let added = banned_hex.iter().filter(|n| !current.contains(n.as_str()));
464 let removed = current.iter().filter(|n| !next.contains(n.as_str()));
465 for target in added.chain(removed) {
466 if !roster.can_act_on_member(&me, owner.as_deref(), target, super::roles::Permissions::BAN) {
467 return Err("you can't ban or unban a member who outranks you (or the owner)".to_string());
468 }
469 }
470 }
471 {
477 let prev: std::collections::HashSet<String> =
478 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
479 let adds = banned_hex.iter().any(|n| !prev.contains(n.as_str()));
480 let cut_needed = (adds || crate::db::community::get_read_cut_pending(&cid)?) && !is_public(community)?;
481 if cut_needed && crate::state::MY_SECRET_KEY.to_keys().is_none() {
482 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());
483 }
484 }
485 let entity_id = super::derive::banlist_locator(&community.id);
487 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
488 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
489 Some((v, h)) => (v + 1, Some(h)),
490 None => (1, None),
491 };
492 let created_at = std::time::SystemTime::now()
493 .duration_since(std::time::UNIX_EPOCH)
494 .map(|d| d.as_secs())
495 .unwrap_or(0);
496 let citation = authority_citation(community, &actor_pk.to_hex());
499 let unsigned = super::roster::build_banlist_edition_unsigned(actor_pk, &community.id, banned_hex, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
500 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign banlist edition: {e}"))?;
501 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
502 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
503
504 let newly_added: Vec<String> = {
507 let prev: std::collections::HashSet<String> =
508 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
509 banned_hex.iter().filter(|n| !prev.contains(n.as_str())).cloned().collect()
510 };
511 let newly_banned = !newly_added.is_empty();
512
513 transport.publish_durable(&outer, &community.relays).await?;
517 if session.is_valid() {
518 crate::db::community::set_community_banlist(&cid, banned_hex, created_at as i64)?;
519 crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
520 }
521
522 if session.is_valid() {
527 for member_hex in &newly_added {
528 strip_member_roles_on_removal(transport, community, member_hex).await;
529 }
530 }
531
532 let need_cut = (newly_banned || crate::db::community::get_read_cut_pending(&cid)?)
547 && session.is_valid()
548 && !is_public(community)?;
549 if need_cut {
550 run_read_cut(transport, community, newly_banned).await?;
553 }
554 Ok(())
555}
556
557pub fn am_i_banned(community: &Community) -> bool {
563 let me = match crate::state::my_public_key() {
564 Some(p) => p.to_hex(),
565 None => return false,
566 };
567 crate::db::community::get_community_banlist(&community.id.to_hex())
568 .unwrap_or_default()
569 .iter()
570 .any(|b| b == &me)
571}
572
573pub async fn retry_pending_read_cut<T: Transport + ?Sized>(
579 transport: &T,
580 community: &Community,
581) -> Result<(), String> {
582 let cid = community.id.to_hex();
583 if !crate::db::community::get_read_cut_pending(&cid)? {
584 return Ok(());
585 }
586 if is_public(community)? {
587 crate::db::community::set_read_cut_pending(&cid, false)?; return Ok(());
589 }
590 let fresh = crate::db::community::load_community(&community.id)?.ok_or("community no longer present")?;
595 run_read_cut(transport, &fresh, false).await
596}
597
598async fn fetch_control_folded<T: Transport + ?Sized>(
608 transport: &T,
609 community: &Community,
610) -> Result<super::roster::FoldedRoster, String> {
611 let z_tags = vec![super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch)];
616 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags, ..Default::default() };
620 let raw = transport.fetch(&query, &community.relays).await?;
621 let inner_editions: Vec<Event> = raw
624 .iter()
625 .take(super::roster::MAX_CONTROL_EDITIONS)
626 .filter_map(|ev| super::roster::open_control_edition(ev, &community.server_root_key).ok())
627 .collect();
628 let fetched = inner_editions.len();
633 let current_epoch = community.server_root_epoch.0;
639 let floors: std::collections::HashMap<String, (u64, [u8; 32])> =
640 crate::db::community::get_all_edition_heads_epoched(&community.id.to_hex())?
641 .into_iter()
642 .filter(|(_, (epoch, _, _))| *epoch == current_epoch)
643 .map(|(entity, (_epoch, version, hash))| (entity, (version, hash)))
644 .collect();
645 let mut folded = super::roster::fold_roster(&inner_editions, &community.id, &floors);
646 folded.fetched = fetched; Ok(folded)
648}
649
650pub async fn fetch_and_apply_control<T: Transport + ?Sized>(
656 transport: &T,
657 community: &Community,
658) -> Result<usize, String> {
659 let session = SessionGuard::capture();
660 let cid = community.id.to_hex();
661 if crate::db::community::get_community_dissolved(&cid)? {
664 return Ok(0);
665 }
666 let folded = fetch_control_folded(transport, community).await?;
667 if !session.is_valid() {
668 return Err("account changed during control fetch".to_string());
669 }
670 if let Some(owner) = proven_owner_hex(community) {
680 let by_fold = folded.dissolved_by.iter().any(|s| s.to_hex() == owner);
681 let by_probe = !by_fold && dissolved_tombstone_present(transport, community, &owner).await;
682 if by_fold || by_probe {
683 let _ = fetch_and_apply_banlist_inner(transport, community, Some(folded.clone())).await;
686 let _ = fetch_and_apply_roles_inner(transport, community, Some(folded.clone())).await;
687 let _ = fetch_and_apply_invite_links_inner(transport, community, Some(folded.clone())).await;
688 let _ = fetch_and_apply_metadata_inner(transport, community, Some(folded.clone())).await;
689 if session.is_valid() {
690 crate::db::community::set_community_dissolved(&cid)?;
691 crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid }));
695 }
696 return Ok(folded.fetched);
697 }
698 }
699 let fetched = folded.fetched;
702 let _ = fetch_and_apply_banlist_inner(transport, community, Some(folded.clone())).await;
703 let _ = fetch_and_apply_roles_inner(transport, community, Some(folded.clone())).await;
704 let _ = fetch_and_apply_invite_links_inner(transport, community, Some(folded.clone())).await;
705 let _ = fetch_and_apply_metadata_inner(transport, community, Some(folded)).await;
706 Ok(fetched)
707}
708
709pub async fn fetch_and_apply_banlist<T: Transport + ?Sized>(
710 transport: &T,
711 community: &Community,
712) -> Result<Vec<String>, String> {
713 fetch_and_apply_banlist_inner(transport, community, None).await
714}
715
716async fn fetch_and_apply_banlist_inner<T: Transport + ?Sized>(
717 transport: &T,
718 community: &Community,
719 prefolded: Option<super::roster::FoldedRoster>,
720) -> Result<Vec<String>, String> {
721 let session = SessionGuard::capture();
722 let cid = community.id.to_hex();
723 let folded = match prefolded {
724 Some(f) => f,
725 None => fetch_control_folded(transport, community).await?,
726 };
727 let owner = proven_owner_hex(community);
730 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
731 if !session.is_valid() {
732 return Err("account changed during banlist fetch".to_string());
733 }
734 if let (Some(author), Some(head)) = (folded.banlist_author, &folded.banlist_head) {
735 let author_hex = author.to_hex();
740 let held: std::collections::HashSet<String> =
741 crate::db::community::get_community_banlist(&cid)?.into_iter().collect();
742 let next: std::collections::HashSet<&str> = folded.banned.iter().map(|s| s.as_str()).collect();
743 let added = folded.banned.iter().filter(|n| !held.contains(n.as_str()));
744 let removed = held.iter().filter(|n| !next.contains(n.as_str()));
745 let citation = folded.banlist_head.as_ref().and_then(|h| h.citation.as_ref());
751 let banner_grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(&community.id, &author.to_bytes()));
752 let pinned = super::roster::authority_citation_satisfied(&folded.heads, owner.as_deref(), &author_hex, &banner_grant_hex, citation);
753 let authed = pinned
754 && added.chain(removed).all(|target| {
755 authorized.can_act_on_member(&author_hex, owner.as_deref(), target, super::roles::Permissions::BAN)
756 });
757 let held_version = crate::db::community::get_edition_head(&cid, &head.entity_hex)?.map(|(v, _)| v).unwrap_or(0);
758 if authed && head.version > held_version {
759 crate::db::community::set_community_banlist(&cid, &folded.banned, head.version as i64)?;
760 crate::db::community::set_edition_head(&cid, &head.entity_hex, head.version, &head.self_hash)?;
761 return Ok(folded.banned);
762 }
763 }
764 crate::db::community::get_community_banlist(&cid)
766}
767
768pub async fn set_member_grant<T: Transport + ?Sized>(
773 transport: &T,
774 community: &Community,
775 member_hex: &str,
776 role_ids: Vec<String>,
777) -> Result<(), String> {
778 let session = SessionGuard::capture();
779 let signer = active_signer().await?;
782 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the grant edition")?;
783 let cid = community.id.to_hex();
784 let grant = super::roles::MemberGrant { member: member_hex.to_string(), role_ids };
785
786 let member_bytes = crate::simd::hex::hex_to_bytes_32(member_hex);
789 let entity_id = super::derive::grant_locator(&community.id, &member_bytes);
790 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
791 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
792 Some((v, h)) => (v + 1, Some(h)),
793 None => (1, None),
794 };
795 let created_at = std::time::SystemTime::now()
796 .duration_since(std::time::UNIX_EPOCH)
797 .map(|d| d.as_secs())
798 .unwrap_or(0);
799
800 let citation = authority_citation(community, &actor_pk.to_hex());
807 let unsigned = super::roster::build_grant_edition_unsigned(actor_pk, &community.id, &grant, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
808 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign grant edition: {e}"))?;
809 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
810 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
814
815 let is_full_revoke = grant.role_ids.is_empty();
816 let mut roster = crate::db::community::get_community_roles(&cid)?;
818 roster.grants.retain(|g| g.member != member_hex);
819 if !grant.role_ids.is_empty() {
820 roster.grants.push(grant);
821 }
822
823 transport.publish_durable(&outer, &community.relays).await?;
829 if session.is_valid() {
830 crate::db::community::set_community_roles(&cid, &roster, created_at as i64)?;
831 crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
832 }
833
834 if is_full_revoke && session.is_valid() {
842 if let Ok(folded) = fetch_control_folded(transport, community).await {
843 if session.is_valid() {
844 let current = crate::db::community::load_community(&community.id)?.unwrap_or_else(|| community.clone());
845 if folded.root_author.map(|a| a.to_hex()).as_deref() == Some(member_hex) {
846 if let Some(meta) = &folded.root_meta {
847 let mut c = current.clone();
848 c.name = meta.name.clone();
849 c.description = meta.description.clone();
850 c.icon = meta.icon.clone();
851 c.banner = meta.banner.clone();
852 let _ = republish_community_metadata(transport, &c).await;
853 }
854 }
855 for cm in &folded.channel_meta {
856 if cm.author.to_hex() == member_hex
857 && current.channels.iter().any(|ch| ch.id.0 == cm.channel_id)
858 {
859 let _ = republish_channel_metadata(
860 transport, ¤t, &crate::community::ChannelId(cm.channel_id), &cm.meta.name,
861 ).await;
862 }
863 }
864 }
865 }
866 }
867 Ok(())
868}
869
870pub fn is_proven_owner(community: &Community) -> bool {
875 match crate::state::my_public_key() {
876 Some(me) => proven_owner_hex(community).as_deref() == Some(me.to_hex().as_str()),
877 None => false,
878 }
879}
880
881pub fn caller_can_manage_roles(community: &Community) -> bool {
885 let me = match crate::state::my_public_key() {
886 Some(p) => p,
887 None => return false,
888 };
889 let cid = community.id.to_hex();
890 let is_owner = community
891 .owner_attestation
892 .as_ref()
893 .and_then(|a| super::owner::verify_owner_attestation(a, &cid))
894 .map(|pk| pk == me)
895 .unwrap_or(false);
896 if is_owner {
897 return true; }
899 crate::db::community::get_community_roles(&cid)
900 .unwrap_or_default()
901 .has_permission(&me.to_hex(), super::roles::Permissions::MANAGE_ROLES)
902}
903
904pub fn caller_has_permission(community: &Community, permission: u64) -> bool {
908 let me = match crate::state::my_public_key() {
909 Some(p) => p,
910 None => return false,
911 };
912 crate::db::community::get_community_roles(&community.id.to_hex())
913 .unwrap_or_default()
914 .is_authorized(&me.to_hex(), proven_owner_hex(community).as_deref(), permission)
915}
916
917pub fn caller_can_manage_role_id(community: &Community, role_id: &str) -> bool {
922 let me = match crate::state::my_public_key() {
923 Some(p) => p.to_hex(),
924 None => return false,
925 };
926 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
927 let position = match roster.role(role_id) {
928 Some(r) => r.position,
929 None => return false,
930 };
931 roster.can_manage_position(&me, proven_owner_hex(community).as_deref(), position)
932}
933
934#[derive(Debug, Clone, Default, serde::Serialize)]
939pub struct CommunityCapabilities {
940 pub manage_metadata: bool,
941 pub manage_channels: bool,
942 pub create_invite: bool,
943 pub kick: bool,
944 pub ban: bool,
945 pub manage_messages: bool,
946 pub manage_roles: bool,
947}
948
949pub fn caller_capabilities(community: &Community) -> CommunityCapabilities {
950 use super::roles::Permissions as P;
951 let me_hex = match crate::state::my_public_key() {
952 Some(p) => p.to_hex(),
953 None => return CommunityCapabilities::default(),
954 };
955 let owner = proven_owner_hex(community);
956 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
957 let has = |bit: u64| roster.is_authorized(&me_hex, owner.as_deref(), bit);
958 CommunityCapabilities {
959 manage_metadata: has(P::MANAGE_METADATA),
960 manage_channels: has(P::MANAGE_CHANNELS),
961 create_invite: has(P::CREATE_INVITE),
962 kick: has(P::KICK),
963 ban: has(P::BAN),
964 manage_messages: has(P::MANAGE_MESSAGES),
965 manage_roles: has(P::MANAGE_ROLES),
966 }
967}
968
969fn authority_citation(community: &Community, actor_hex: &str) -> Option<super::edition::AuthorityCitation> {
977 if proven_owner_hex(community).as_deref() == Some(actor_hex) {
978 return None;
979 }
980 let cid = community.id.to_hex();
981 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
982 let entity_id = super::derive::grant_locator(&community.id, &actor_bytes);
983 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
984 crate::db::community::get_edition_head(&cid, &entity_hex)
985 .ok()
986 .flatten()
987 .map(|(version, edition_hash)| super::edition::AuthorityCitation { entity_id, version, edition_hash })
988}
989
990fn proven_owner_hex(community: &Community) -> Option<String> {
993 let cid = community.id.to_hex();
994 community
995 .owner_attestation
996 .as_ref()
997 .and_then(|a| super::owner::verify_owner_attestation(a, &cid))
998 .map(|pk| pk.to_hex())
999}
1000
1001pub fn can_moderation_hide(community: &Community, actor_hex: &str, author_hex: &str) -> bool {
1007 let owner = proven_owner_hex(community);
1008 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
1009 roster.can_act_on_member(actor_hex, owner.as_deref(), author_hex, super::roles::Permissions::MANAGE_MESSAGES)
1010}
1011
1012fn rotator_is_authorized(
1020 cid: &str,
1021 roster: &super::roles::CommunityRoles,
1022 owner_hex: Option<&str>,
1023 rotator_hex: &str,
1024 permission: u64,
1025) -> bool {
1026 if owner_hex != Some(rotator_hex)
1027 && crate::db::community::get_community_banlist(cid)
1028 .unwrap_or_default()
1029 .iter()
1030 .any(|b| b == rotator_hex)
1031 {
1032 return false;
1033 }
1034 roster.is_authorized(rotator_hex, owner_hex, permission)
1035}
1036
1037fn caller_can_manage_role(
1043 community: &Community,
1044 roster: &super::roles::CommunityRoles,
1045 role_id: &str,
1046 member_hex: &str,
1047) -> Result<(), String> {
1048 let me = crate::state::my_public_key().ok_or("no active identity")?.to_hex();
1049 let owner = proven_owner_hex(community);
1050 let owner_ref = owner.as_deref();
1051 let role = roster.role(role_id).ok_or("no such role")?;
1052 if !roster.can_manage_position(&me, owner_ref, role.position) {
1053 return Err("you can only manage roles below your own".to_string());
1054 }
1055 if !roster.can_manage_member(&me, owner_ref, member_hex) {
1056 return Err("you can't manage a member who outranks you".to_string());
1057 }
1058 Ok(())
1059}
1060
1061pub async fn grant_role<T: Transport + ?Sized>(
1065 transport: &T,
1066 community: &Community,
1067 member: nostr_sdk::prelude::PublicKey,
1068 role_id: &str,
1069) -> Result<(), String> {
1070 let cid = community.id.to_hex();
1071 let member_hex = member.to_hex();
1072 let roster = crate::db::community::get_community_roles(&cid)?;
1073 caller_can_manage_role(community, &roster, role_id, &member_hex)?;
1074 let mut role_ids: Vec<String> = roster
1076 .grants
1077 .iter()
1078 .find(|g| g.member == member_hex)
1079 .map(|g| g.role_ids.clone())
1080 .unwrap_or_default();
1081 if !role_ids.iter().any(|r| r == role_id) {
1082 role_ids.push(role_id.to_string());
1083 }
1084
1085 set_member_grant(transport, community, &member_hex, role_ids).await
1089}
1090
1091pub async fn revoke_role<T: Transport + ?Sized>(
1098 transport: &T,
1099 community: &Community,
1100 member: nostr_sdk::prelude::PublicKey,
1101 role_id: &str,
1102) -> Result<(), String> {
1103 let cid = community.id.to_hex();
1104 let member_hex = member.to_hex();
1105 let roster = crate::db::community::get_community_roles(&cid)?;
1106 caller_can_manage_role(community, &roster, role_id, &member_hex)?;
1107 let role_ids: Vec<String> = roster
1108 .grants
1109 .iter()
1110 .find(|g| g.member == member_hex)
1111 .map(|g| g.role_ids.iter().filter(|r| r.as_str() != role_id).cloned().collect())
1112 .unwrap_or_default();
1113 set_member_grant(transport, community, &member_hex, role_ids).await
1114}
1115
1116pub async fn fetch_and_apply_roles<T: Transport + ?Sized>(
1122 transport: &T,
1123 community: &Community,
1124) -> Result<super::roles::CommunityRoles, String> {
1125 fetch_and_apply_roles_inner(transport, community, None).await
1126}
1127
1128async fn fetch_and_apply_roles_inner<T: Transport + ?Sized>(
1129 transport: &T,
1130 community: &Community,
1131 prefolded: Option<super::roster::FoldedRoster>,
1132) -> Result<super::roles::CommunityRoles, String> {
1133 let session = SessionGuard::capture();
1134 let cid = community.id.to_hex();
1135 let folded = match prefolded {
1136 Some(f) => f,
1137 None => fetch_control_folded(transport, community).await?,
1138 };
1139
1140 if !session.is_valid() {
1141 return Err("account changed during roles fetch".to_string());
1142 }
1143 for head in &folded.heads {
1152 crate::db::community::set_edition_head(&cid, &head.entity_hex, head.version, &head.self_hash)?;
1153 }
1154 if folded.heads.is_empty() {
1159 return crate::db::community::get_community_roles(&cid);
1160 }
1161 let authorized = super::roster::authorize_delegation(&folded, proven_owner_hex(community).as_deref());
1165 crate::db::community::set_community_roles(&cid, &authorized, 0)?;
1166 Ok(authorized)
1167}
1168
1169pub async fn publish_owner_hide<T: Transport + ?Sized>(
1174 transport: &T,
1175 community: &Community,
1176 channel: &Channel,
1177 target_message_id: &str,
1178) -> Result<(), String> {
1179 let signer = active_signer().await?;
1184 let me_pk = crate::state::my_public_key().ok_or("no local identity to sign the hide")?;
1185 let me = me_pk.to_hex();
1186 {
1187 let target_author = {
1188 let st = crate::state::STATE.lock().await;
1189 st.find_message(target_message_id).and_then(|(_, m)| m.npub)
1190 };
1191 let author = target_author
1192 .ok_or("can't resolve the target message's author to authorize the hide")?;
1193 if !can_moderation_hide(community, &me, &author) {
1194 return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
1195 }
1196 }
1197 let ms = std::time::SystemTime::now()
1198 .duration_since(std::time::UNIX_EPOCH)
1199 .map(|d| d.as_millis() as u64)
1200 .unwrap_or(0);
1201 let citation = authority_citation(community, &me);
1207 let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
1208 let inner = super::envelope::build_inner_full(
1209 me_pk, &channel.id, channel.epoch,
1210 event_kind::COMMUNITY_DELETE, "", ms, Some(target_message_id), &[], &extra,
1211 )
1212 .sign(&signer)
1213 .await
1214 .map_err(|e| format!("sign hide: {e}"))?;
1215 let _ = publish_signed_message(transport, community, channel, &inner, true).await?;
1216 Ok(())
1217}
1218
1219pub async fn delete_message<T: Transport + ?Sized>(
1224 transport: &T,
1225 message_id: &str,
1226) -> Result<(), String> {
1227 let session = SessionGuard::capture();
1228 if !session.is_valid() {
1229 return Err("account changed; aborting delete".to_string());
1230 }
1231 let (ephemeral, outer_event_id_hex, relays) = match crate::db::community::get_message_key(message_id)? {
1235 Some(v) => v,
1236 None => {
1237 return Err("no retained key for this message (not yours, or already deleted)".to_string())
1238 }
1239 };
1240 let id = EventId::from_hex(&outer_event_id_hex).map_err(|e| e.to_string())?;
1241 delete_own_message(transport, &relays, &ephemeral, id).await?;
1242 crate::db::community::delete_message_key(message_id)?;
1244 Ok(())
1245}
1246
1247pub fn accept_invite(invite: &CommunityInvite) -> Result<Community, String> {
1260 let session = SessionGuard::capture();
1261 let community = super::invite::accept_invite(invite)?; if let Some(existing) = crate::db::community::load_community(&community.id)? {
1264 if is_proven_owner(&existing) {
1265 return Err("you already own this Community".to_string());
1266 }
1267 if existing.server_root_key.as_bytes() != community.server_root_key.as_bytes() {
1271 return Err(
1272 "invite reuses a known Community id under a different authority — rejected"
1273 .to_string(),
1274 );
1275 }
1276 }
1277
1278 if !session.is_valid() {
1279 return Err("account changed during invite accept".to_string());
1280 }
1281 crate::db::community::save_community(&community)?;
1282 Ok(community)
1283}
1284
1285pub async fn preload_community(invite: &super::invite::CommunityInvite) {
1292 let Ok(community) = super::invite::accept_invite(invite) else { return };
1293 let Some(channel) = community.channels.first() else { return };
1294 let cid = community.id.to_hex();
1295 crate::community::cache::begin_preload(&cid);
1297 let transport = super::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1298 match super::send::fetch_channel_page(&transport, &community, channel, None, None, 50).await {
1300 Ok(page) if !page.is_empty() => crate::community::cache::finish_preload(&cid, page),
1301 _ => crate::community::cache::abort_preload(&cid),
1303 }
1304
1305 let prune_relays = community.relays.clone();
1309 let prune_id = community.id;
1310 let guard = crate::state::SessionGuard::capture();
1311 tokio::spawn(async move {
1312 tokio::time::sleep(crate::community::cache::PRELOAD_TTL).await;
1313 if !guard.is_valid() {
1314 return;
1315 }
1316 if matches!(crate::db::community::load_community(&prune_id), Ok(Some(_))) {
1319 return;
1320 }
1321 crate::community::cache::abort_preload(&prune_id.to_hex());
1323 super::transport::prune_unneeded_community_relays(&prune_relays).await;
1324 });
1325}
1326
1327pub async fn republish_community_metadata<T: Transport + ?Sized>(
1332 transport: &T,
1333 community: &Community,
1334) -> Result<(), String> {
1335 let session = SessionGuard::capture();
1336 let cid = community.id.to_hex();
1337 let signer = active_signer().await?;
1338 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the metadata edition")?;
1339 let owner = proven_owner_hex(community);
1340 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
1341 if !roster.is_authorized(&actor_pk.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_METADATA) {
1342 return Err("only a member with manage-metadata authority can edit the community".to_string());
1343 }
1344 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &cid)? {
1349 Some((v, h)) => (v + 1, Some(h)),
1350 None => (1, None),
1351 };
1352 let created = std::time::SystemTime::now()
1353 .duration_since(std::time::UNIX_EPOCH)
1354 .map(|d| d.as_secs())
1355 .unwrap_or(0);
1356 let meta = super::metadata::CommunityMetadata::of(community);
1357 let citation = authority_citation(community, &actor_pk.to_hex());
1362 let unsigned = super::roster::build_community_root_edition_unsigned(actor_pk, &community.id, &meta, version, prev_hash.as_ref(), created, citation.as_ref())?;
1363 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign community-root edition: {e}"))?;
1364 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1365 transport.publish_durable(&outer, &community.relays).await?;
1366 if session.is_valid() {
1367 crate::db::community::save_community(community)?;
1368 let h = super::version::edition_hash(&community.id.0, version, prev_hash.as_ref(), inner.content.as_bytes());
1369 crate::db::community::set_edition_head_with_id(&cid, &cid, version, &h, &inner.id.to_bytes())?;
1372 }
1373 Ok(())
1374}
1375
1376pub async fn republish_channel_metadata<T: Transport + ?Sized>(
1382 transport: &T,
1383 community: &Community,
1384 channel_id: &crate::community::ChannelId,
1385 new_name: &str,
1386) -> Result<(), String> {
1387 let session = SessionGuard::capture();
1388 let cid = community.id.to_hex();
1389 let ch_hex = channel_id.to_hex();
1390 if !community.channels.iter().any(|c| &c.id == channel_id) {
1391 return Err("no such channel in this community".to_string());
1392 }
1393 let signer = active_signer().await?;
1394 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the channel metadata edition")?;
1395 let owner = proven_owner_hex(community);
1396 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
1397 if !roster.is_authorized(&actor_pk.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
1398 return Err("only a member with manage-channels authority can rename a channel".to_string());
1399 }
1400 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &ch_hex)? {
1401 Some((v, h)) => (v + 1, Some(h)),
1402 None => (1, None),
1403 };
1404 let created = std::time::SystemTime::now()
1405 .duration_since(std::time::UNIX_EPOCH)
1406 .map(|d| d.as_secs())
1407 .unwrap_or(0);
1408 let meta = super::metadata::ChannelMetadata { name: new_name.to_string() };
1409 let citation = authority_citation(community, &actor_pk.to_hex());
1412 let unsigned = super::roster::build_channel_metadata_edition_unsigned(actor_pk, channel_id, &meta, version, prev_hash.as_ref(), created, citation.as_ref())?;
1413 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign channel-metadata edition: {e}"))?;
1414 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1415 transport.publish_durable(&outer, &community.relays).await?;
1416 if session.is_valid() {
1417 let mut current = crate::db::community::load_community(&community.id)?.ok_or("community no longer present")?;
1418 if let Some(ch) = current.channels.iter_mut().find(|c| &c.id == channel_id) {
1419 ch.name = new_name.to_string();
1420 }
1421 crate::db::community::save_community(¤t)?;
1422 let h = super::version::edition_hash(&channel_id.0, version, prev_hash.as_ref(), inner.content.as_bytes());
1423 crate::db::community::set_edition_head_with_id(&cid, &ch_hex, version, &h, &inner.id.to_bytes())?;
1424 }
1425 Ok(())
1426}
1427
1428fn generate_invite_label() -> String {
1441 use rand::Rng;
1442 const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
1443 let mut rng = rand::thread_rng();
1444 (0..6).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect()
1445}
1446
1447pub async fn create_public_invite<T: Transport + ?Sized>(
1448 transport: &T,
1449 community: &Community,
1450 expires_at: Option<u64>,
1451 label: Option<String>,
1452) -> Result<(String, String), String> {
1453 if !caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1454 return Err("you need the create-invite permission to mint a public invite".to_string());
1455 }
1456 let session = SessionGuard::capture();
1457
1458 let existing = crate::db::community::list_public_invites(&community.id.to_hex()).unwrap_or_default();
1462 let label_taken = |cand: &str| {
1463 existing.iter().any(|r| r.label.as_deref().map(|e| e.eq_ignore_ascii_case(cand)).unwrap_or(false))
1464 };
1465 let label = match label {
1466 Some(l) if !l.trim().is_empty() => {
1467 let l = l.trim().to_string();
1468 if label_taken(&l) {
1469 return Err(format!("You already have an invite link labeled \u{201c}{l}\u{201d}. Pick a different label."));
1470 }
1471 Some(l)
1472 }
1473 _ => {
1475 let mut l = generate_invite_label();
1476 while label_taken(&l) {
1477 l = generate_invite_label();
1478 }
1479 Some(l)
1480 }
1481 };
1482
1483 let creator_npub = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok());
1486 let token = public_invite::new_token();
1487 let event = build_public_invite_event(community, &token, expires_at, creator_npub, label.clone()).map_err(|e| e.to_string())?;
1488 transport.publish_durable(&event, &community.relays).await?;
1489
1490 if !session.is_valid() {
1493 return Err("account changed during public invite creation".to_string());
1494 }
1495 let token_hex = crate::simd::hex::bytes_to_hex_32(&token);
1496 let url = public_invite::encode_invite_url(&community.relays, &token);
1497 crate::db::community::save_public_invite(
1498 &token_hex,
1499 &community.id.to_hex(),
1500 &url,
1501 expires_at.map(|e| e as i64),
1502 label.as_deref(),
1503 )?;
1504 republish_my_invite_links(transport, community).await?;
1507 Ok((token_hex, url))
1508}
1509
1510pub async fn latest_invite_preview<T: Transport + ?Sized>(
1516 transport: &T,
1517 bundle: &public_invite::PublicInviteBundle,
1518) -> public_invite::PublicInvitePreview {
1519 let snapshot = bundle.preview.clone();
1520 let Ok(community) = super::invite::accept_invite(&bundle.join) else {
1521 return snapshot;
1522 };
1523 let Ok(folded) = fetch_control_folded(transport, &community).await else {
1524 return snapshot;
1525 };
1526 let owner = proven_owner_hex(&community);
1527 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
1528 match folded.root_candidates.iter().find(|c| {
1529 authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_METADATA)
1530 }) {
1531 Some(c) => public_invite::PublicInvitePreview {
1532 name: c.meta.name.clone(),
1533 description: c.meta.description.clone(),
1534 icon: c.meta.icon.clone(),
1535 },
1536 None => snapshot,
1537 }
1538}
1539
1540pub async fn fetch_public_invite<T: Transport + ?Sized>(
1544 transport: &T,
1545 relays: &[String],
1546 token: &[u8; 32],
1547) -> Result<PublicInviteBundle, String> {
1548 let query = Query {
1552 kinds: vec![event_kind::APPLICATION_SPECIFIC],
1553 d_tags: vec![locator_hex(token)],
1554 ..Default::default()
1555 };
1556 let events = transport.fetch(&query, relays).await?;
1557 let (mut bundle_at, mut bundle, mut revoked_at) = (0u64, None, None::<u64>);
1563 for ev in &events {
1564 match parse_public_invite_event(ev, token) {
1565 Ok(b) => if bundle.is_none() || ev.created_at.as_secs() > bundle_at {
1566 bundle_at = ev.created_at.as_secs();
1567 bundle = Some(b);
1568 },
1569 Err(super::public_invite::PublicInviteError::Revoked) => {
1570 let at = ev.created_at.as_secs();
1571 if revoked_at.map_or(true, |r| at > r) { revoked_at = Some(at); }
1572 }
1573 Err(_) => {} }
1575 }
1576 match (bundle, revoked_at) {
1577 (Some(b), Some(r)) if bundle_at > r => Ok(b), (_, Some(_)) => Err("this invite was revoked".to_string()),
1579 (Some(b), None) => Ok(b),
1580 (None, None) => Err("no public invite found at that link (revoked, never posted, or shadowed)".to_string()),
1581 }
1582}
1583
1584pub fn accept_public_invite(bundle: &PublicInviteBundle, now_secs: u64) -> Result<Community, String> {
1588 if bundle.is_expired(now_secs) {
1589 return Err("this invite link has expired".to_string());
1590 }
1591 let mut community = accept_invite(&bundle.join)?;
1592 if bundle.preview.description.is_some() || bundle.preview.icon.is_some() {
1595 community.description = bundle.preview.description.clone();
1596 community.icon = bundle.preview.icon.clone();
1597 crate::db::community::save_community(&community)?;
1598 }
1599 Ok(community)
1600}
1601
1602pub async fn revoke_public_invite<T: Transport + ?Sized>(
1609 transport: &T,
1610 community: &Community,
1611 token: &[u8; 32],
1612) -> Result<(), String> {
1613 let session = SessionGuard::capture();
1614 let cid = community.id.to_hex();
1615 let token_hex = crate::simd::hex::bytes_to_hex_32(token);
1616 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1617 if !crate::db::community::list_public_invites(&cid)?.iter().any(|r| r.token == token_hex) {
1620 return Ok(());
1621 }
1622 let my_locators_before: Vec<String> = crate::db::community::list_public_invites(&cid)?
1623 .iter()
1624 .filter(|r| r.expires_at.map_or(true, |e| (e as u64) > now))
1625 .map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
1626 .collect();
1627 let _ = fetch_and_apply_invite_links(transport, community).await;
1631 if !session.is_valid() {
1632 return Err("account changed during invite revoke".to_string());
1633 }
1634 let this_locator = public_invite::locator_hex(token);
1640 let cached_aggregate: std::collections::BTreeSet<String> =
1641 crate::db::community::get_community_invite_registry(&cid)?.into_iter().collect();
1642 let my_before: std::collections::BTreeSet<String> = my_locators_before.iter().cloned().collect();
1643 let others: std::collections::BTreeSet<String> = cached_aggregate.difference(&my_before).cloned().collect();
1644 let my_after: std::collections::BTreeSet<String> =
1645 my_before.iter().filter(|l| **l != this_locator).cloned().collect();
1646 let would_empty_aggregate = others.is_empty() && my_after.is_empty();
1647 if would_empty_aggregate && crate::state::MY_SECRET_KEY.to_keys().is_none() {
1648 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());
1649 }
1650 if let Ok(tombstone) = public_invite::build_public_invite_tombstone(token) {
1660 let _ = transport.publish_durable(&tombstone, &community.relays).await;
1661 }
1662 if !session.is_valid() {
1664 return Err("account changed during invite revoke".to_string());
1665 }
1666 crate::db::community::delete_public_invite(&token_hex)?;
1667 republish_my_invite_links(transport, community).await?;
1669 if session.is_valid() {
1670 let aggregate_after: Vec<String> = others.union(&my_after).cloned().collect();
1671 crate::db::community::set_community_invite_registry(&cid, &aggregate_after)?;
1672 }
1673 if would_empty_aggregate {
1674 run_read_cut(transport, community, true).await?;
1678 }
1679 Ok(())
1680}
1681
1682async fn dissolved_tombstone_present<T: Transport + ?Sized>(transport: &T, community: &Community, owner_hex: &str) -> bool {
1695 let z = super::derive::dissolved_pseudonym(&community.id);
1696 let q = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
1697 for ev in transport.fetch(&q, &community.relays).await.unwrap_or_default() {
1698 if super::roster::dissolved_tombstone_signer(&ev, &community.id).map(|s| s.to_hex()) == Some(owner_hex.to_string()) {
1699 return true;
1700 }
1701 }
1702 false
1703}
1704
1705pub async fn dissolve_community<T: Transport + ?Sized>(
1706 transport: &T,
1707 community: &Community,
1708) -> Result<(), String> {
1709 let session = SessionGuard::capture();
1710 let cid = community.id.to_hex();
1711
1712 if !is_proven_owner(community) {
1714 return Err("only the community owner can dissolve (delete) the community".to_string());
1715 }
1716 let signer = active_signer().await?;
1717 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the dissolution")?;
1718
1719 let created_at = std::time::SystemTime::now()
1723 .duration_since(std::time::UNIX_EPOCH)
1724 .map(|d| d.as_secs())
1725 .unwrap_or(0);
1726 let unsigned = super::roster::build_group_dissolved_edition_unsigned(actor_pk, &community.id, created_at);
1727 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign dissolution tombstone: {e}"))?;
1728 let stable = super::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id)?;
1732 transport.publish_durable(&stable, &community.relays).await?;
1733 if let Ok(outer) = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch) {
1736 let _ = transport.publish_durable(&outer, &community.relays).await;
1737 }
1738 if !session.is_valid() {
1739 return Err("account changed during dissolution".to_string());
1740 }
1741
1742 if caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1748 let _ = publish_my_invite_links(transport, community, &[]).await;
1749 if let Ok(records) = crate::db::community::list_public_invites(&cid) {
1750 for r in records {
1751 let token = crate::simd::hex::hex_to_bytes_32(&r.token);
1752 if let Ok(tombstone) = public_invite::build_public_invite_tombstone(&token) {
1753 let _ = transport.publish_durable(&tombstone, &community.relays).await;
1754 }
1755 let _ = crate::db::community::delete_public_invite(&r.token);
1756 }
1757 }
1758 }
1759
1760 if !session.is_valid() {
1762 return Err("account changed during dissolution".to_string());
1763 }
1764 crate::db::community::set_community_dissolved(&cid)?;
1765 Ok(())
1766}
1767
1768pub async fn publish_my_invite_links<T: Transport + ?Sized>(
1775 transport: &T,
1776 community: &Community,
1777 my_locators: &[String],
1778) -> Result<(), String> {
1779 let session = SessionGuard::capture();
1780 if !caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1781 return Err("you need the create-invite permission to publish invite links".to_string());
1782 }
1783 let cid = community.id.to_hex();
1784 let signer = active_signer().await?;
1785 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the invite links")?;
1786 let entity_id = super::derive::invite_links_locator(&community.id, &actor_pk.to_bytes());
1787 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
1788 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
1789 Some((v, h)) => (v + 1, Some(h)),
1790 None => (1, None),
1791 };
1792 let created_at = std::time::SystemTime::now()
1793 .duration_since(std::time::UNIX_EPOCH)
1794 .map(|d| d.as_secs())
1795 .unwrap_or(0);
1796 let citation = authority_citation(community, &actor_pk.to_hex());
1798 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())?;
1799 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign invite-links edition: {e}"))?;
1800 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1801 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
1802 transport.publish_durable(&outer, &community.relays).await?;
1803 if session.is_valid() {
1804 crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
1805 let mut agg: std::collections::BTreeSet<String> =
1808 crate::db::community::get_community_invite_registry(&cid)?.into_iter().collect();
1809 agg.extend(my_locators.iter().cloned());
1810 crate::db::community::set_community_invite_registry(&cid, &agg.into_iter().collect::<Vec<_>>())?;
1811 crate::db::community::upsert_invite_link_set(&cid, &actor_pk.to_hex(), my_locators)?;
1812 }
1813 Ok(())
1814}
1815
1816pub async fn fetch_and_apply_invite_links<T: Transport + ?Sized>(
1822 transport: &T,
1823 community: &Community,
1824) -> Result<Vec<String>, String> {
1825 fetch_and_apply_invite_links_inner(transport, community, None).await
1826}
1827
1828async fn fetch_and_apply_invite_links_inner<T: Transport + ?Sized>(
1829 transport: &T,
1830 community: &Community,
1831 prefolded: Option<super::roster::FoldedRoster>,
1832) -> Result<Vec<String>, String> {
1833 let session = SessionGuard::capture();
1834 let cid = community.id.to_hex();
1835 let folded = match prefolded {
1836 Some(f) => f,
1837 None => fetch_control_folded(transport, community).await?,
1838 };
1839 if !session.is_valid() {
1840 return Err("account changed during invite-links fetch".to_string());
1841 }
1842 let owner = proven_owner_hex(community);
1843 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
1844 let mut aggregate: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1845 let mut per_creator: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1847 for set in &folded.invite_link_sets {
1848 if !authorized.is_authorized(&set.creator.to_hex(), owner.as_deref(), super::roles::Permissions::CREATE_INVITE) {
1851 continue;
1852 }
1853 let held = crate::db::community::get_edition_head(&cid, &set.head.entity_hex)?.map(|(v, _)| v).unwrap_or(0);
1854 if set.head.version > held {
1855 crate::db::community::set_edition_head(&cid, &set.head.entity_hex, set.head.version, &set.head.self_hash)?;
1856 }
1857 aggregate.extend(set.locators.iter().cloned());
1858 per_creator.push(crate::db::community::InviteLinkSetRow {
1859 creator_hex: set.creator.to_hex(),
1860 locators: set.locators.clone(),
1861 });
1862 }
1863 let aggregate: Vec<String> = aggregate.into_iter().collect();
1864 crate::db::community::set_community_invite_registry(&cid, &aggregate)?;
1865 crate::db::community::replace_invite_link_sets(&cid, &per_creator)?;
1866 Ok(aggregate)
1867}
1868
1869pub async fn fetch_and_apply_metadata<T: Transport + ?Sized>(
1877 transport: &T,
1878 community: &Community,
1879) -> Result<(), String> {
1880 fetch_and_apply_metadata_inner(transport, community, None).await
1881}
1882
1883async fn fetch_and_apply_metadata_inner<T: Transport + ?Sized>(
1884 transport: &T,
1885 community: &Community,
1886 prefolded: Option<super::roster::FoldedRoster>,
1887) -> Result<(), String> {
1888 let session = SessionGuard::capture();
1889 let cid = community.id.to_hex();
1890 let folded = match prefolded {
1891 Some(f) => f,
1892 None => fetch_control_folded(transport, community).await?,
1893 };
1894 if !session.is_valid() {
1895 return Err("account changed during metadata fetch".to_string());
1896 }
1897 let owner = proven_owner_hex(community);
1898 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
1899 let manage = super::roles::Permissions::MANAGE_METADATA;
1902 let manage_channels = super::roles::Permissions::MANAGE_CHANNELS;
1903
1904 let mut current = match crate::db::community::load_community(&community.id)? {
1907 Some(c) => c,
1908 None => return Ok(()),
1909 };
1910 let mut dirty = false;
1911 let mut head_updates: Vec<(String, u64, [u8; 32], [u8; 32], bool)> = Vec::new();
1915
1916 let decide = |entity_hex: &str, head: &super::roster::EntityHead| -> Result<Option<bool>, String> {
1923 let held = crate::db::community::get_edition_head(&cid, entity_hex)?;
1924 let held_v = held.map(|(v, _)| v).unwrap_or(0);
1925 if head.version > held_v {
1926 return Ok(Some(false)); }
1928 if head.version == held_v && held.map(|(_, h)| h) != Some(head.self_hash) {
1929 let held_id = crate::db::community::get_edition_head_inner_id(&cid, entity_hex)?;
1930 if held_id.is_none() || Some(head.inner_id) < held_id {
1931 return Ok(Some(true)); }
1933 }
1934 Ok(None)
1935 };
1936
1937 if let Some(c) = folded.root_candidates.iter()
1942 .find(|c| authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), manage))
1943 {
1944 let head = &c.head;
1945 if let Some(is_converge) = decide(&head.entity_hex, head)? {
1946 let meta = &c.meta;
1947 current.name = meta.name.clone();
1956 current.description = meta.description.clone();
1957 current.icon = meta.icon.clone();
1958 current.banner = meta.banner.clone();
1959 dirty = true;
1960 head_updates.push((head.entity_hex.clone(), head.version, head.self_hash, head.inner_id, is_converge));
1961 }
1962 }
1963 let mut resolved_channels: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1969 for cm in &folded.channel_candidates {
1970 if resolved_channels.contains(&cm.channel_id) {
1971 continue; }
1973 if !authorized.is_authorized(&cm.author.to_hex(), owner.as_deref(), manage_channels) {
1974 continue; }
1976 resolved_channels.insert(cm.channel_id);
1977 let Some(is_converge) = decide(&cm.head.entity_hex, &cm.head)? else { continue };
1978 if let Some(ch) = current.channels.iter_mut().find(|c| c.id.0 == cm.channel_id) {
1979 ch.name = cm.meta.name.clone();
1980 dirty = true;
1981 head_updates.push((cm.head.entity_hex.clone(), cm.head.version, cm.head.self_hash, cm.head.inner_id, is_converge));
1982 }
1983 }
1984
1985 if dirty && session.is_valid() {
1986 crate::db::community::save_community(¤t)?;
1987 for (entity_hex, version, self_hash, inner_id, is_converge) in &head_updates {
1990 if *is_converge {
1991 crate::db::community::converge_edition_head(&cid, entity_hex, *version, self_hash, inner_id)?;
1992 } else {
1993 crate::db::community::set_edition_head_with_id(&cid, entity_hex, *version, self_hash, inner_id)?;
1994 }
1995 }
1996 }
1997 Ok(())
1998}
1999
2000pub fn is_public(community: &Community) -> Result<bool, String> {
2008 Ok(!crate::db::community::get_community_invite_registry(&community.id.to_hex())?.is_empty())
2009}
2010
2011async fn republish_my_invite_links<T: Transport + ?Sized>(
2016 transport: &T,
2017 community: &Community,
2018) -> Result<Vec<String>, String> {
2019 let cid = community.id.to_hex();
2020 let now = std::time::SystemTime::now()
2021 .duration_since(std::time::UNIX_EPOCH)
2022 .map(|d| d.as_secs())
2023 .unwrap_or(0);
2024 let locators: Vec<String> = crate::db::community::list_public_invites(&cid)?
2025 .iter()
2026 .filter(|r| r.expires_at.map_or(true, |e| (e as u64) > now))
2027 .map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
2028 .collect();
2029 publish_my_invite_links(transport, community, &locators).await?;
2030 Ok(locators)
2031}
2032
2033async fn observe_channel_activity<T: Transport + ?Sized>(
2039 transport: &T,
2040 community: &Community,
2041) -> Result<(), String> {
2042 let session = SessionGuard::capture();
2043 let my_pk = crate::state::my_public_key().ok_or("no local identity to observe channel activity")?;
2044 for channel in &community.channels {
2045 let events = super::send::fetch_channel_events(transport, community, channel)
2046 .await
2047 .unwrap_or_default();
2048 if !session.is_valid() {
2049 return Err("account changed during activity observation".to_string());
2050 }
2051 let outcomes = {
2052 let mut st = crate::state::STATE.lock().await;
2053 super::inbound::process_channel_batch(&mut st, &events, channel, &my_pk)
2054 };
2055 let ch_hex = channel.id.to_hex();
2056 for o in &outcomes {
2057 match o {
2058 super::inbound::IncomingEvent::NewMessage(m)
2059 | super::inbound::IncomingEvent::Updated { message: m, .. } => {
2060 let _ = crate::db::events::save_message(&ch_hex, m).await;
2061 }
2062 super::inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label } => {
2063 let et = if *joined {
2064 crate::stored_event::SystemEventType::MemberJoined
2065 } else {
2066 crate::stored_event::SystemEventType::MemberLeft
2067 };
2068 let note = invited_by.as_ref().map(|by| match invited_label {
2069 Some(l) if !l.is_empty() => format!("{by}|{l}"),
2070 _ => by.clone(),
2071 });
2072 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;
2073 }
2074 super::inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at } => {
2075 persist_webxdc_signal(&ch_hex, npub, topic_id, node_addr.as_deref(), event_id, *created_at).await;
2076 }
2077 _ => {}
2078 }
2079 }
2080 }
2081 Ok(())
2082}
2083
2084pub async fn sync_before_admin_write<T: Transport + ?Sized>(
2095 transport: &T,
2096 community: &Community,
2097 observe_activity: bool,
2098) -> Result<Community, String> {
2099 if catch_up_server_root(transport, community).await?.removed {
2101 return Err("you have been removed from this community".to_string());
2102 }
2103 let community = crate::db::community::load_community(&community.id)?
2104 .ok_or("community gone during admin sync")?;
2105 let cid = community.id.to_hex();
2106 let responded = fetch_and_apply_control(transport, &community).await.map(|n| n > 0).unwrap_or(false);
2113 let hold_local_heads = !crate::db::community::get_all_edition_heads_epoched(&cid)?.is_empty();
2114 if hold_local_heads && !responded {
2115 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());
2116 }
2117 let community = crate::db::community::load_community(&community.id)?
2118 .ok_or("community gone during admin sync")?;
2119 if observe_activity {
2122 let _ = observe_channel_activity(transport, &community).await;
2123 }
2124 crate::db::community::load_community(&community.id)?.ok_or("community gone during admin sync".to_string())
2125}
2126
2127async fn run_read_cut<T: Transport + ?Sized>(
2137 transport: &T,
2138 community: &Community,
2139 fresh: bool,
2140) -> Result<(), String> {
2141 let cid = community.id.to_hex();
2142 let session = SessionGuard::capture();
2143 if fresh {
2144 let base = crate::db::community::load_community(&community.id)?
2147 .map(|c| c.server_root_epoch.0)
2148 .unwrap_or(community.server_root_epoch.0);
2149 crate::db::community::set_read_cut_target_epoch(&cid, base.saturating_add(1))?;
2150 }
2151 crate::db::community::set_read_cut_pending(&cid, true)?;
2152 reseal_base_to_observed(transport, community).await?;
2153 if session.is_valid() {
2154 crate::db::community::set_read_cut_pending(&cid, false)?;
2155 }
2156 Ok(())
2157}
2158
2159async fn reseal_base_to_observed<T: Transport + ?Sized>(
2169 transport: &T,
2170 community: &Community,
2171) -> Result<(), String> {
2172 let session = SessionGuard::capture();
2173 let cid = community.id.to_hex();
2174 let community = &sync_before_admin_write(transport, community, true).await?;
2179 let participants: Vec<nostr_sdk::PublicKey> = crate::db::community::community_member_activity(&cid)?
2183 .into_iter()
2184 .filter_map(|(npub, _)| nostr_sdk::PublicKey::parse(&npub).ok())
2185 .collect();
2186 let target = crate::db::community::get_read_cut_target_epoch(&cid)?;
2196 if community.server_root_epoch.0 < target {
2197 rotate_server_root(transport, community, &participants).await?;
2198 if !session.is_valid() {
2199 return Err("account changed during re-founding".to_string());
2200 }
2201 }
2202 let community = crate::db::community::load_community(&community.id)?
2208 .ok_or("community gone after base rotation")?;
2209 let cut_epoch = community.server_root_epoch.0;
2210 let prior_root = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, cut_epoch.saturating_sub(1))?
2216 .unwrap_or(*community.server_root_key.as_bytes()); for channel in &community.channels {
2218 let ch_hex = channel.id.to_hex();
2219 if crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex)? >= cut_epoch {
2222 continue;
2223 }
2224 rotate_channel(transport, &community, &channel.id, &participants, &prior_root).await?;
2225 if !session.is_valid() {
2226 return Err("account changed during re-founding".to_string());
2227 }
2228 crate::db::community::mark_channel_rekeyed_at_server_epoch(&cid, &ch_hex, cut_epoch)?;
2229 }
2230 Ok(())
2231}
2232
2233#[derive(Debug, PartialEq, Eq)]
2235pub enum RekeyOutcome {
2236 Applied { head_advanced: bool },
2239 NotARecipient,
2242}
2243
2244pub fn apply_channel_rekey(
2255 community: &Community,
2256 parsed: &super::rekey::ParsedRekey,
2257) -> Result<RekeyOutcome, String> {
2258 let session = SessionGuard::capture();
2262
2263 let channel_id = match parsed.scope {
2265 super::derive::RekeyScope::Channel(c) => c,
2266 super::derive::RekeyScope::ServerRoot => {
2267 return Err("server-root rotation uses apply_server_root_rekey, not the channel path".to_string())
2268 }
2269 };
2270 if !community.channels.iter().any(|c| c.id == channel_id) {
2271 return Err("rekey targets a channel not in this community".to_string());
2272 }
2273 let cid = community.id.to_hex();
2274 let channel_hex = channel_id.to_hex();
2275
2276 let owner = proven_owner_hex(community);
2283 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
2284 crate::log_warn!("rekey apply: roster read failed ({e}); authorizing owner only");
2287 Default::default()
2288 });
2289 if !roster.is_authorized(
2290 &parsed.rotator.to_hex(),
2291 owner.as_deref(),
2292 super::roles::Permissions::MANAGE_CHANNELS,
2293 ) {
2294 return Err("rekey rotator lacks MANAGE_CHANNELS authority".to_string());
2295 }
2296
2297 if let Some(prev_key) = crate::db::community::held_epoch_key(&cid, &channel_hex, parsed.prev_epoch.0)? {
2307 if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_key) != parsed.prev_key_commitment {
2308 crate::log_warn!(
2309 "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",
2310 parsed.new_epoch.0, parsed.prev_epoch.0
2311 );
2312 }
2313 }
2314
2315 let my_keys = crate::state::MY_SECRET_KEY
2317 .to_keys()
2318 .ok_or("no local identity to open the rekey blob")?;
2319 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
2320 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
2321 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
2322 Some(b) => b,
2323 None => return Ok(RekeyOutcome::NotARecipient),
2324 };
2325 let new_key =
2326 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
2327
2328 if !session.is_valid() {
2330 return Err("session changed during rekey apply".to_string());
2331 }
2332 let head_advanced =
2333 crate::db::community::advance_channel_epoch(&cid, &channel_hex, parsed.new_epoch.0, &new_key)?;
2334 Ok(RekeyOutcome::Applied { head_advanced })
2335}
2336
2337fn mint_or_reuse_rotation_key(cid: &str, scope_id: &str, epoch: u64) -> Result<zeroize::Zeroizing<[u8; 32]>, String> {
2343 if let Some(k) = crate::db::community::held_epoch_key(cid, scope_id, epoch)? {
2344 return Ok(zeroize::Zeroizing::new(k));
2345 }
2346 let k = zeroize::Zeroizing::new(super::random_32());
2347 crate::db::community::store_epoch_key(cid, scope_id, epoch, &k)?;
2348 Ok(k)
2349}
2350
2351async fn publish_rekey_chunked<T, F>(
2359 transport: &T,
2360 relays: &[String],
2361 blobs: &[super::rekey::RekeyBlob],
2362 build: F,
2363) -> Result<(), String>
2364where
2365 T: Transport + ?Sized,
2366 F: Fn(&[super::rekey::RekeyBlob]) -> Result<Event, String>,
2367{
2368 if blobs.is_empty() {
2369 return Err("rekey has no recipients".to_string());
2370 }
2371 for chunk in blobs.chunks(super::rekey::MAX_REKEY_BLOBS) {
2372 let event = build(chunk)?;
2373 transport.publish_durable(&event, relays).await?;
2374 }
2375 Ok(())
2376}
2377
2378pub async fn rotate_channel<T: Transport + ?Sized>(
2390 transport: &T,
2391 community: &Community,
2392 channel_id: &super::ChannelId,
2393 recipients: &[nostr_sdk::PublicKey],
2394 envelope_root: &[u8; 32],
2400) -> Result<u64, String> {
2401 let session = SessionGuard::capture();
2402 let cid = community.id.to_hex();
2403
2404 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)")?;
2408 let owner = proven_owner_hex(community);
2409 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
2410 if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
2411 return Err("not authorized to rotate this channel (no MANAGE_CHANNELS)".to_string());
2412 }
2413
2414 let channel = community
2418 .channels
2419 .iter()
2420 .find(|c| &c.id == channel_id)
2421 .ok_or("channel not found in community")?;
2422 let prev_epoch = channel.epoch;
2423 let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2424 let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, channel.key.as_bytes());
2425 let new_key = mint_or_reuse_rotation_key(&cid, &channel_id.to_hex(), new_epoch.0)?;
2428
2429 let mut seen = std::collections::HashSet::new();
2433 let mut blobs = Vec::new();
2434 for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
2435 if !seen.insert(pk.to_hex()) {
2436 continue;
2437 }
2438 blobs.push(super::rekey::build_rekey_blob(
2439 my_keys.secret_key(), pk, super::derive::RekeyScope::Channel(*channel_id), new_epoch, &new_key,
2440 )?);
2441 }
2442
2443 publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
2445 super::rekey::build_channel_rekey_event(
2446 &Keys::generate(), &my_keys, envelope_root, channel_id,
2447 new_epoch, prev_epoch, &prev_commit, chunk,
2448 )
2449 })
2450 .await?;
2451 if !session.is_valid() {
2452 return Err("session changed during channel rotation".to_string());
2453 }
2454 crate::db::community::advance_channel_epoch(&cid, &channel_id.to_hex(), new_epoch.0, &new_key)?;
2455 Ok(new_epoch.0)
2456}
2457
2458fn emit_rekey_progress(label: &str, pct: u8) {
2462 crate::emit_event("community_rekey_progress", &serde_json::json!({ "label": label, "pct": pct }));
2463}
2464
2465pub(crate) async fn rotate_server_root<T: Transport + ?Sized>(
2481 transport: &T,
2482 community: &Community,
2483 recipients: &[nostr_sdk::PublicKey],
2484) -> Result<u64, String> {
2485 let session = SessionGuard::capture();
2486 let cid = community.id.to_hex();
2487
2488 if crate::db::community::get_community_dissolved(&cid)? {
2490 return Err("community is dissolved; it cannot be re-founded".to_string());
2491 }
2492
2493 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)")?;
2504 let owner = proven_owner_hex(community);
2505 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
2506 if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::BAN) {
2507 return Err("not authorized to rotate the server root (no BAN)".to_string());
2508 }
2509
2510 let fresh = crate::db::community::load_community(&community.id)?
2516 .ok_or("community gone before base rotation")?;
2517 let community = &fresh;
2518 let prev_epoch = community.server_root_epoch;
2519 let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("server-root epoch overflow")?);
2520 let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, community.server_root_key.as_bytes());
2522 let new_root = mint_or_reuse_rotation_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2524 emit_rekey_progress("Rerolling community keys...", 5);
2525
2526 let sealed = prepare_reanchor_control_plane(transport, community, &new_root, new_epoch).await?;
2532 if !session.is_valid() {
2533 return Err("session changed during re-founding acquire".to_string());
2534 }
2535
2536 let total_recipients = (recipients.len() + 1).max(1); let mut seen = std::collections::HashSet::new();
2538 let mut blobs = Vec::new();
2539 for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
2540 if !seen.insert(pk.to_hex()) {
2541 continue;
2542 }
2543 blobs.push(super::rekey::build_rekey_blob(
2544 my_keys.secret_key(), pk, super::derive::RekeyScope::ServerRoot, new_epoch, &new_root,
2545 )?);
2546 emit_rekey_progress(
2547 &format!("Preparing keys for members ({}/{})...", blobs.len(), total_recipients),
2548 (5 + 35 * blobs.len() / total_recipients) as u8,
2549 );
2550 }
2551
2552 emit_rekey_progress("Sending keys to members...", 42);
2556 publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
2557 super::rekey::build_server_root_rekey_event(
2558 &Keys::generate(), &my_keys, community.server_root_key.as_bytes(), &community.id,
2559 new_epoch, prev_epoch, &prev_commit, chunk,
2560 )
2561 })
2562 .await?;
2563
2564 let snapshot = publish_reanchor_snapshot(transport, &community.relays, sealed).await?;
2572 if snapshot.iter().any(|e| !e.published) {
2573 return Err(
2574 "re-founding aborted: a snapshot edition did not land (rate-limited / unreachable relay?); base head NOT advanced".to_string()
2575 );
2576 }
2577 if !session.is_valid() {
2578 return Err("session changed during server-root rotation".to_string());
2579 }
2580 emit_rekey_progress("Finalizing...", 98);
2581 crate::db::community::advance_server_root_epoch(&cid, new_epoch.0, &new_root)?;
2583 for e in &snapshot {
2587 crate::db::community::set_edition_head_with_id(&cid, &e.entity_hex, e.version, &e.self_hash, &e.inner_id)?;
2588 }
2589 Ok(new_epoch.0)
2590}
2591
2592pub(crate) struct SnapshotEntry {
2627 pub entity_hex: String,
2628 pub version: u64,
2629 pub self_hash: [u8; 32],
2630 pub inner_id: [u8; 32],
2631 pub published: bool,
2632}
2633
2634pub(crate) async fn prepare_reanchor_control_plane<T: Transport + ?Sized>(
2640 transport: &T,
2641 community: &Community,
2642 new_root: &[u8; 32],
2643 new_epoch: super::Epoch,
2644) -> Result<Vec<(Event, SnapshotEntry)>, String> {
2645 let session = SessionGuard::capture();
2646 let cid = community.id.to_hex();
2647
2648 let z = super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
2656 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
2657 let outers = transport.fetch(&query, &community.relays).await?;
2658 if !session.is_valid() {
2659 return Err("session changed during re-founding fetch".to_string());
2660 }
2661 let mut by_hash: std::collections::HashMap<[u8; 32], (Event, [u8; 32])> = std::collections::HashMap::new();
2663 for outer in &outers {
2664 if let Ok(inner) = super::roster::open_control_edition(outer, &community.server_root_key) {
2665 if let Ok(parsed) = super::edition::parse_edition_inner(&inner) {
2666 by_hash.insert(parsed.self_hash, (inner, parsed.inner_id));
2667 }
2668 }
2669 }
2670
2671 let new_root_key = super::ServerRootKey(*new_root);
2675 let mut sealed: Vec<(Event, SnapshotEntry)> = Vec::new();
2676 for (entity_hex, (epoch, version, self_hash)) in crate::db::community::get_all_edition_heads_epoched(&cid)? {
2677 if epoch != community.server_root_epoch.0 {
2678 continue; }
2680 let (inner, inner_id) = by_hash.get(&self_hash).ok_or_else(|| {
2681 format!("re-founding aborted: head edition for entity {entity_hex} (v{version}) not fetchable — aborting so no member is stranded")
2682 })?;
2683 let outer = super::roster::seal_control_edition(&Keys::generate(), inner, &new_root_key, &community.id, new_epoch)?;
2684 sealed.push((outer, SnapshotEntry { entity_hex, version, self_hash, inner_id: *inner_id, published: false }));
2685 }
2686 Ok(sealed)
2687}
2688
2689pub(crate) async fn publish_reanchor_snapshot<T: Transport + ?Sized>(
2693 transport: &T,
2694 relays: &[String],
2695 sealed: Vec<(Event, SnapshotEntry)>,
2696) -> Result<Vec<SnapshotEntry>, String> {
2697 use futures_util::stream::StreamExt;
2700 let total = sealed.len().max(1);
2701 let done = std::sync::atomic::AtomicUsize::new(0);
2702 let done_ref = &done;
2703 emit_rekey_progress(&format!("Re-founding community (0/{total})..."), 50);
2704 let out: Vec<SnapshotEntry> = futures_util::stream::iter(sealed.into_iter().map(|(ev, mut entry)| async move {
2705 entry.published = transport.publish_durable(&ev, relays).await.is_ok();
2706 let n = done_ref.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
2707 emit_rekey_progress(&format!("Re-founding community ({n}/{total})..."), (50 + 45 * n / total) as u8);
2708 entry
2709 }))
2710 .buffer_unordered(4)
2711 .collect()
2712 .await;
2713 Ok(out)
2714}
2715
2716#[cfg(test)]
2719pub(crate) async fn reanchor_control_plane<T: Transport + ?Sized>(
2720 transport: &T,
2721 community: &Community,
2722 new_root: &[u8; 32],
2723 new_epoch: super::Epoch,
2724) -> Result<Vec<SnapshotEntry>, String> {
2725 let sealed = prepare_reanchor_control_plane(transport, community, new_root, new_epoch).await?;
2726 publish_reanchor_snapshot(transport, &community.relays, sealed).await
2727}
2728
2729pub fn apply_server_root_rekey(
2737 community: &Community,
2738 parsed: &super::rekey::ParsedRekey,
2739) -> Result<RekeyOutcome, String> {
2740 let session = SessionGuard::capture();
2741
2742 if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
2744 return Err("not a server-root rekey (channel rekeys use apply_channel_rekey)".to_string());
2745 }
2746 let cid = community.id.to_hex();
2747
2748 if crate::db::community::get_community_dissolved(&cid)? {
2751 return Err("community is dissolved; base epoch cannot advance".to_string());
2752 }
2753
2754 let owner = proven_owner_hex(community);
2761 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
2762 crate::log_warn!("base rekey apply: roster read failed ({e}); authorizing owner only");
2763 Default::default()
2764 });
2765 if !rotator_is_authorized(&cid, &roster, owner.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
2766 return Err("base rekey rotator lacks server-wide rotation authority (BAN)".to_string());
2767 }
2768
2769 if let Some(prev_root) =
2776 crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, parsed.prev_epoch.0)?
2777 {
2778 if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_root) != parsed.prev_key_commitment {
2779 crate::log_warn!(
2780 "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",
2781 parsed.new_epoch.0, parsed.prev_epoch.0
2782 );
2783 }
2784 }
2785
2786 let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local identity to open the base rekey blob")?;
2788 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
2789 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
2790 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
2791 Some(b) => b,
2792 None => return Ok(RekeyOutcome::NotARecipient),
2793 };
2794 let new_root =
2795 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
2796
2797 if !session.is_valid() {
2798 return Err("session changed during base rekey apply".to_string());
2799 }
2800 let head_advanced = crate::db::community::advance_server_root_epoch(&cid, parsed.new_epoch.0, &new_root)?;
2801 Ok(RekeyOutcome::Applied { head_advanced })
2802}
2803
2804const REKEY_CATCHUP_WINDOW: u64 = 64;
2808const MAX_REKEY_CATCHUP_ROUNDS: usize = 64;
2811
2812async fn heal_channel_fork_epochs<T: Transport + ?Sized>(
2824 transport: &T,
2825 community: &Community,
2826 channel_id: &super::ChannelId,
2827 cid: &str,
2828 channel_hex: &str,
2829 epochs: &std::collections::BTreeSet<u64>,
2830 server_roots: &[[u8; 32]],
2831 session: &SessionGuard,
2832) -> Result<(), String> {
2833 if epochs.is_empty() {
2834 return Ok(());
2835 }
2836 let owner_hex = proven_owner_hex(community);
2837 let roster = crate::db::community::get_community_roles(cid).unwrap_or_default();
2838 let mut winner: std::collections::BTreeMap<u64, [u8; 32]> = std::collections::BTreeMap::new();
2841 for sr in server_roots {
2842 let z_tags: Vec<String> = epochs
2843 .iter()
2844 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
2845 .collect();
2846 let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
2847 for ev in transport.fetch(&q, &community.relays).await.unwrap_or_default() {
2848 let Ok(p) = super::rekey::open_rekey_event(&ev, sr) else { continue };
2849 if !matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) || !epochs.contains(&p.new_epoch.0) {
2850 continue;
2851 }
2852 if !rotator_is_authorized(cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::MANAGE_CHANNELS) {
2853 continue;
2854 }
2855 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);
2857 }
2858 }
2859 for (epoch, win_key) in winner {
2860 if !session.is_valid() {
2861 return Err("session changed during channel convergence".to_string());
2862 }
2863 if let Ok(Some(cur)) = crate::db::community::held_epoch_key(cid, channel_hex, epoch) {
2868 if win_key < cur {
2869 match crate::db::community::converge_channel_epoch(cid, channel_hex, epoch, &win_key) {
2872 Ok(false) => crate::log_trace!("channel heal: converge of epoch {epoch} did not apply (head moved)"),
2873 Err(e) => crate::log_trace!("channel heal: converge of epoch {epoch} errored: {e}"),
2874 Ok(true) => {}
2875 }
2876 }
2877 }
2878 }
2879 Ok(())
2880}
2881
2882pub async fn catch_up_channel_rekeys<T: Transport + ?Sized>(
2897 transport: &T,
2898 community: &Community,
2899 channel_id: &super::ChannelId,
2900) -> Result<u64, String> {
2901 let session = SessionGuard::capture();
2902 let server_root = community.server_root_key.as_bytes();
2903 let cid = community.id.to_hex();
2904 let channel_hex = channel_id.to_hex();
2905 let mut server_roots: Vec<[u8; 32]> = crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX)
2911 .unwrap_or_default()
2912 .into_iter()
2913 .map(|(_, k)| k)
2914 .collect();
2915 if !server_roots.iter().any(|r| r == server_root) {
2916 server_roots.push(*server_root); }
2918 let mut head = community
2919 .channels
2920 .iter()
2921 .find(|c| &c.id == channel_id)
2922 .ok_or("channel not found in community")?
2923 .epoch
2924 .0;
2925
2926 let mut forked_epochs: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
2930
2931 for _round in 0..MAX_REKEY_CATCHUP_ROUNDS {
2932 let window_top = head.saturating_add(REKEY_CATCHUP_WINDOW);
2933 let mut parsed: Vec<super::rekey::ParsedRekey> = Vec::new();
2937 for sr in &server_roots {
2938 let z_tags: Vec<String> = (head.saturating_add(1)..=window_top)
2939 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(e)).to_hex())
2940 .collect();
2941 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
2942 for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
2947 if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
2948 if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
2949 parsed.push(p);
2950 }
2951 }
2952 }
2953 }
2954 if parsed.is_empty() {
2955 break; }
2957 parsed.sort_by_key(|p| p.new_epoch.0);
2959 let max_found = parsed.last().map(|p| p.new_epoch.0).unwrap_or(head);
2960
2961 let head_before = head;
2962 let mut removed = false;
2963 let mut by_epoch: std::collections::BTreeMap<u64, Vec<&super::rekey::ParsedRekey>> = std::collections::BTreeMap::new();
2968 for p in &parsed {
2969 by_epoch.entry(p.new_epoch.0).or_default().push(p);
2970 }
2971 for (e, chunks) in by_epoch {
2972 if !session.is_valid() {
2973 return Err("session changed during rekey catch-up".to_string());
2974 }
2975 let mut applied = false;
2976 let mut saw_not_recipient = false;
2977 for p in &chunks {
2978 match apply_channel_rekey(community, p) {
2979 Ok(RekeyOutcome::Applied { .. }) => {
2980 applied = true;
2981 break;
2982 }
2983 Ok(RekeyOutcome::NotARecipient) => saw_not_recipient = true,
2984 Err(err) => crate::log_warn!("rekey catch-up: skipping epoch {e} chunk: {err}"),
2985 }
2986 }
2987 if applied {
2988 if let Some(p) = chunks.first() {
2994 let pe = p.prev_epoch.0;
2995 if let Ok(Some(prev_key)) = crate::db::community::held_epoch_key(&cid, &channel_hex, pe) {
2996 if super::rekey::epoch_key_commitment(p.prev_epoch, &prev_key) != p.prev_key_commitment {
2997 forked_epochs.insert(pe);
2998 }
2999 }
3000 }
3001 if e > head + 1 {
3004 crate::log_warn!(
3005 "rekey catch-up: channel epochs {}..={} not recovered (key gap; history unreadable until re-fetched)",
3006 head + 1, e - 1
3007 );
3008 }
3009 head = head.max(e);
3010 } else if saw_not_recipient {
3011 removed = true;
3014 break;
3015 }
3016 }
3018
3019 if removed || head == head_before || max_found < window_top {
3022 break;
3023 }
3024 }
3025
3026 let held: std::collections::HashSet<u64> = crate::db::community::held_epoch_keys(&cid, &channel_hex)
3033 .unwrap_or_default()
3034 .into_iter()
3035 .map(|(e, _)| e.0)
3036 .collect();
3037 let missing: Vec<u64> = (0..head).filter(|e| !held.contains(e)).collect();
3038 if !missing.is_empty() {
3039 for sr in &server_roots {
3040 if !session.is_valid() {
3041 return Err("session changed during rekey gap-fill".to_string());
3042 }
3043 let z_tags: Vec<String> = missing
3044 .iter()
3045 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
3046 .collect();
3047 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3048 for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
3051 if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
3052 if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
3053 let _ = apply_channel_rekey(community, &p); }
3055 }
3056 }
3057 }
3058 }
3059
3060 if head > 0 && session.is_valid() {
3067 let lo = head.saturating_sub(REKEY_CATCHUP_WINDOW).max(1);
3068 let mut epochs: std::collections::BTreeSet<u64> = (lo..=head).collect();
3069 epochs.append(&mut forked_epochs);
3070 let _ = heal_channel_fork_epochs(transport, community, channel_id, &cid, &channel_hex, &epochs, &server_roots, &session).await;
3071 }
3072 Ok(head)
3073}
3074
3075const MAX_BASE_CATCHUP_STEPS: usize = 256;
3078
3079fn peek_my_server_root(parsed: &super::rekey::ParsedRekey) -> Result<Option<[u8; 32]>, String> {
3093 if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
3094 return Ok(None);
3095 }
3096 let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local key to open a base rekey blob")?;
3097 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
3098 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3099 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
3100 Some(b) => b,
3101 None => return Ok(None),
3102 };
3103 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).map(Some)
3104}
3105
3106fn peek_my_channel_key(parsed: &super::rekey::ParsedRekey) -> Option<[u8; 32]> {
3110 let my_keys = crate::state::MY_SECRET_KEY.to_keys()?;
3111 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator).ok()?;
3112 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3113 let mine = parsed.blobs.iter().find(|b| b.locator == my_locator)?;
3114 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).ok()
3115}
3116
3117pub async fn catch_up_server_root<T: Transport + ?Sized>(
3118 transport: &T,
3119 community: &Community,
3120) -> Result<BaseCatchup, String> {
3121 let session = SessionGuard::capture();
3122 let cid = community.id.to_hex();
3123 let mut head = community.server_root_epoch.0;
3124 let mut removed = false;
3129 let mut current_root: [u8; 32] = *community.server_root_key.as_bytes();
3131
3132 for _step in 0..MAX_BASE_CATCHUP_STEPS {
3133 let next = match head.checked_add(1) {
3134 Some(n) => n,
3135 None => break,
3136 };
3137 let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(current_root), &community.id, super::Epoch(next)).to_hex();
3138 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
3139 let events = transport.fetch(&query, &community.relays).await?;
3140 if events.is_empty() {
3141 break; }
3143
3144 let chunks: Vec<super::rekey::ParsedRekey> = events
3147 .iter()
3148 .filter_map(|ev| super::rekey::open_rekey_event(ev, ¤t_root).ok())
3149 .filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == next)
3150 .collect();
3151 if chunks.is_empty() {
3152 break; }
3154
3155 if !session.is_valid() {
3156 return Err("session changed during base rekey catch-up".to_string());
3157 }
3158
3159 let owner_hex = proven_owner_hex(community);
3173 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3174 let mut candidates: Vec<(&super::rekey::ParsedRekey, [u8; 32])> = Vec::new();
3175 for parsed in &chunks {
3176 if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
3177 continue;
3178 }
3179 match peek_my_server_root(parsed) {
3180 Ok(Some(root)) => candidates.push((parsed, root)),
3181 Ok(None) => {}
3182 Err(err) => crate::log_warn!("base rekey catch-up: epoch {next} peek: {err}"),
3183 }
3184 }
3185 let applied = match candidates.into_iter().min_by(|a, b| a.1.cmp(&b.1)) {
3186 Some((parsed, _)) => match apply_server_root_rekey(community, parsed) {
3187 Ok(RekeyOutcome::Applied { .. }) => true,
3188 Ok(RekeyOutcome::NotARecipient) => false, Err(err) => { crate::log_warn!("base rekey catch-up: epoch {next} apply: {err}"); false }
3190 },
3191 None => {
3192 let owner = proven_owner_hex(community);
3197 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3198 if chunks.iter().any(|p| rotator_is_authorized(&cid, &roster, owner.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)) {
3199 removed = true;
3200 }
3201 false }
3203 };
3204 if !applied {
3205 break;
3206 }
3207 match crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, next)? {
3209 Some(root) => {
3210 current_root = root;
3211 head = next;
3212 }
3213 None => {
3214 crate::log_warn!("base rekey catch-up: epoch {next} applied but its root is not archived; halting walk");
3217 break;
3218 }
3219 }
3220 }
3221
3222 if head > 0 && !removed {
3229 if let Ok(Some(prior_root)) = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, head - 1) {
3230 let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(prior_root), &community.id, super::Epoch(head)).to_hex();
3231 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
3232 let events = transport.fetch(&query, &community.relays).await.unwrap_or_default();
3233 let chunks: Vec<super::rekey::ParsedRekey> = events
3234 .iter()
3235 .filter_map(|ev| super::rekey::open_rekey_event(ev, &prior_root).ok())
3236 .filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == head)
3237 .collect();
3238 let owner_hex = proven_owner_hex(community);
3239 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3240 let mut best: Option<(&super::rekey::ParsedRekey, [u8; 32])> = None;
3241 for p in &chunks {
3242 if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN) {
3246 continue;
3247 }
3248 if let Ok(Some(root)) = peek_my_server_root(p) {
3249 if best.as_ref().map_or(true, |(_, br)| root < *br) {
3250 best = Some((p, root));
3251 }
3252 }
3253 }
3254 let current_deauthorized = chunks.iter().any(|p| {
3264 matches!(peek_my_server_root(p), Ok(Some(r)) if r == current_root)
3265 && !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)
3266 });
3267 if let Some((winner, win_root)) = best {
3268 let adopt = if current_deauthorized {
3269 win_root != current_root
3270 } else {
3271 win_root < current_root
3272 };
3273 if adopt {
3274 if !session.is_valid() {
3275 return Err("session changed during base convergence".to_string());
3276 }
3277 if apply_server_root_rekey(community, winner).is_ok() {
3280 match crate::db::community::converge_server_root_epoch(&cid, head, &win_root) {
3281 Ok(false) => crate::log_trace!("base heal: converge of epoch {head} did not apply (head moved)"),
3282 Err(e) => crate::log_trace!("base heal: converge of epoch {head} errored: {e}"),
3283 Ok(true) => {}
3284 }
3285 current_root = win_root;
3286 if let Ok(Some(fresh)) = crate::db::community::load_community(&community.id) {
3287 let _ = fetch_and_apply_control(transport, &fresh).await;
3288 }
3289 }
3290 }
3291 }
3292 }
3293 }
3294 let _ = current_root; Ok(BaseCatchup { epoch: head, removed })
3296}
3297
3298#[derive(Debug, Clone, Copy)]
3302pub struct BaseCatchup {
3303 pub epoch: u64,
3304 pub removed: bool,
3305}
3306
3307#[cfg(test)]
3308mod tests {
3309 use super::*;
3310 use crate::community::send::fetch_channel_messages;
3311 use crate::community::transport::{memory::MemoryRelay, Query, Transport};
3312 use nostr_sdk::prelude::{EventBuilder, Kind};
3313
3314 struct FailingRelay;
3317 #[async_trait::async_trait]
3318 impl Transport for FailingRelay {
3319 async fn publish(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
3320 Err("relay unreachable".to_string())
3321 }
3322 async fn publish_durable(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
3323 Err("relay unreachable".to_string())
3324 }
3325 async fn fetch(&self, _query: &Query, _relays: &[String]) -> Result<Vec<Event>, String> {
3326 Ok(Vec::new())
3327 }
3328 }
3329
3330 struct RekeyFailingRelay {
3334 inner: MemoryRelay,
3335 fail_rekey: std::sync::atomic::AtomicBool,
3336 }
3337 impl RekeyFailingRelay {
3338 fn new() -> Self {
3339 Self { inner: MemoryRelay::new(), fail_rekey: std::sync::atomic::AtomicBool::new(true) }
3340 }
3341 fn allow_rekey(&self) {
3342 self.fail_rekey.store(false, std::sync::atomic::Ordering::Relaxed);
3343 }
3344 fn blocks(&self, event: &Event) -> bool {
3345 self.fail_rekey.load(std::sync::atomic::Ordering::Relaxed)
3346 && event.kind.as_u16() == crate::stored_event::event_kind::COMMUNITY_REKEY
3347 }
3348 }
3349 #[async_trait::async_trait]
3350 impl Transport for RekeyFailingRelay {
3351 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
3352 if self.blocks(event) { return Err("rekey relay down".to_string()); }
3353 self.inner.publish(event, relays).await
3354 }
3355 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
3356 if self.blocks(event) { return Err("rekey relay down".to_string()); }
3357 self.inner.publish_durable(event, relays).await
3358 }
3359 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
3360 self.inner.fetch(query, relays).await
3361 }
3362 }
3363
3364 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(5000);
3365
3366 fn make_test_npub(n: u32) -> String {
3367 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3368 let mut payload = vec![b'q'; 58];
3369 let mut x = n as u64;
3370 let mut i = 58;
3371 while x > 0 && i > 0 {
3372 i -= 1;
3373 payload[i] = BECH32[(x as usize) % 32];
3374 x /= 32;
3375 }
3376 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
3377 }
3378
3379 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
3380 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3381 crate::db::close_database();
3382 let tmp = tempfile::tempdir().unwrap();
3383 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3384 let account = make_test_npub(n);
3385 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3386 crate::db::set_app_data_dir(tmp.path().to_path_buf());
3387 crate::db::set_current_account(account.clone()).unwrap();
3388 crate::db::init_database(&account).unwrap();
3389 let _ = crate::state::take_nostr_client();
3392 let owner = Keys::generate();
3394 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
3395 crate::state::set_my_public_key(owner.public_key());
3396 (tmp, guard)
3397 }
3398
3399 fn saved_community_owned_by(owner: &Keys) -> Community {
3404 use nostr_sdk::JsonUtil;
3405 let mut community = Community::create("HQ", "general", vec!["r".into()]);
3406 let cid = community.id.to_hex();
3407 community.owner_attestation = Some(
3408 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
3409 .sign_with_keys(owner)
3410 .unwrap()
3411 .as_json(),
3412 );
3413 crate::db::community::save_community(&community).unwrap();
3414 community
3415 }
3416
3417 fn attested_community(name: &str, channel: &str, relays: Vec<String>) -> Community {
3421 use nostr_sdk::JsonUtil;
3422 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
3423 let mut community = Community::create(name, channel, relays);
3424 community.owner_attestation = Some(
3425 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &community.id.to_hex())
3426 .sign_with_keys(&owner).unwrap().as_json(),
3427 );
3428 community
3429 }
3430
3431 fn become_local(me: &Keys) {
3433 crate::state::MY_SECRET_KEY.store_from_keys(me, &[]);
3434 crate::state::set_my_public_key(me.public_key());
3435 }
3436
3437 fn owner_channel_rekey(
3440 owner: &Keys,
3441 community: &Community,
3442 recipient_pk: &nostr_sdk::PublicKey,
3443 new_epoch: u64,
3444 new_key: &[u8; 32],
3445 ) -> super::super::rekey::ParsedRekey {
3446 let chan = &community.channels[0];
3447 let scope = super::super::derive::RekeyScope::Channel(chan.id);
3448 let blob = super::super::rekey::build_rekey_blob(
3449 owner.secret_key(), recipient_pk, scope, crate::community::Epoch(new_epoch), new_key,
3450 )
3451 .unwrap();
3452 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes());
3453 let outer = super::super::rekey::build_channel_rekey_event(
3454 &Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
3455 crate::community::Epoch(new_epoch), crate::community::Epoch(0), &commit, &[blob],
3456 )
3457 .unwrap();
3458 super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
3459 }
3460
3461 #[tokio::test]
3466 async fn outer_event_dedup_skips_an_already_persisted_wire_event() {
3467 let (_tmp, _guard) = init_test_db();
3468 let owner = Keys::generate();
3469 let me = Keys::generate();
3470 become_local(&me);
3471 let community = saved_community_owned_by(&owner);
3472 let channel = community.channels[0].clone();
3473 let chan_hex = channel.id.to_hex();
3474
3475 let author = Keys::generate();
3477 let outer = crate::community::envelope::seal_message(
3478 &author, &channel.key, &channel.id, channel.epoch, "gm", 1000,
3479 ).unwrap();
3480 let outer_hex = outer.id.to_hex();
3481
3482 let mut state = crate::state::ChatState::new();
3484 let msg = match crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key()) {
3485 Some(crate::community::inbound::IncomingEvent::NewMessage(m)) => m,
3486 _ => panic!("expected NewMessage from a fresh wire event"),
3487 };
3488 assert_eq!(msg.wrapper_event_id.as_deref(), Some(outer_hex.as_str()),
3489 "the inner must carry its outer wire id as wrapper_event_id");
3490
3491 crate::db::events::save_message(&chan_hex, &msg).await.unwrap();
3493
3494 let mut state2 = crate::state::ChatState::new();
3496 let second = crate::community::inbound::process_incoming(&mut state2, &outer, &channel, &me.public_key());
3497 assert!(second.is_none(), "an already-processed wire event must dedup before decryption");
3498 }
3499
3500 #[tokio::test]
3503 async fn ledger_is_shared_but_negentropy_stays_nip17_only() {
3504 let (_tmp, _guard) = init_test_db();
3505 let dm = [0xA1u8; 32];
3506 let concord = [0xC0u8; 32];
3507 crate::db::wrappers::save_processed_wrapper(&dm, 100, crate::db::wrappers::TRANSPORT_NIP17).unwrap();
3508 crate::db::wrappers::save_processed_wrapper(&concord, 200, crate::db::wrappers::TRANSPORT_CONCORD).unwrap();
3509
3510 assert!(crate::db::wrappers::processed_wrapper_exists(&dm));
3512 assert!(crate::db::wrappers::processed_wrapper_exists(&concord));
3513
3514 let items = crate::db::wrappers::load_negentropy_items().unwrap();
3516 assert_eq!(items.len(), 1, "negentropy must exclude concord wrappers");
3517 assert_eq!(items[0].0.to_bytes(), dm);
3518 }
3519
3520 #[tokio::test]
3524 async fn non_message_subkind_dedups_via_the_shared_ledger() {
3525 let (_tmp, _guard) = init_test_db();
3526 let owner = Keys::generate();
3527 let me = Keys::generate();
3528 become_local(&me);
3529 let community = saved_community_owned_by(&owner);
3530 let channel = community.channels[0].clone();
3531
3532 let author = Keys::generate();
3534 let inner = super::super::envelope::build_inner_typed(
3535 author.public_key(), &channel.id, channel.epoch,
3536 crate::stored_event::event_kind::COMMUNITY_PRESENCE, "join", 5, None, &[],
3537 ).sign_with_keys(&author).unwrap();
3538 let outer = super::super::envelope::seal_with_signed_inner(
3539 &Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch,
3540 ).unwrap();
3541
3542 let mut state = crate::state::ChatState::new();
3544 let first = crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key());
3545 assert!(matches!(first, Some(crate::community::inbound::IncomingEvent::Presence { .. })),
3546 "expected a Presence outcome");
3547 assert!(crate::db::wrappers::processed_wrapper_exists(&outer.id.to_bytes()),
3548 "a non-message sub-kind must record its outer id in the shared ledger");
3549
3550 let second = crate::community::inbound::process_incoming(&mut crate::state::ChatState::new(), &outer, &channel, &me.public_key());
3552 assert!(second.is_none(), "a re-fetched presence must dedup via the shared ledger");
3553 }
3554
3555 #[test]
3556 fn apply_channel_rekey_recovers_and_advances_head() {
3557 let (_tmp, _guard) = init_test_db();
3558 let owner = Keys::generate(); let me = Keys::generate();
3560 become_local(&me);
3561 let community = saved_community_owned_by(&owner);
3562 let cid = community.id.to_hex();
3563 let chan_hex = community.channels[0].id.to_hex();
3564 let new_key = [0xCDu8; 32];
3565
3566 let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &new_key);
3567 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
3568 assert_eq!(outcome, RekeyOutcome::Applied { head_advanced: true });
3569
3570 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(new_key));
3572 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3573 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
3574 assert_eq!(reloaded.channels[0].key.as_bytes(), &new_key);
3575 assert!(crate::db::community::held_epoch_key(&cid, &chan_hex, 0).unwrap().is_some());
3577 }
3578
3579 #[test]
3580 fn apply_channel_rekey_accepts_matching_continuity() {
3581 let (_tmp, _guard) = init_test_db();
3584 let owner = Keys::generate();
3585 let me = Keys::generate();
3586 become_local(&me);
3587 let community = saved_community_owned_by(&owner);
3588 let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
3590 assert_eq!(
3591 apply_channel_rekey(&community, &parsed).unwrap(),
3592 RekeyOutcome::Applied { head_advanced: true },
3593 "a rekey whose prior-key commitment matches the held genesis key applies"
3594 );
3595 }
3596
3597 #[test]
3598 fn advance_channel_epoch_archives_when_no_head_row() {
3599 let (_tmp, _guard) = init_test_db();
3602 let cid = "f".repeat(64);
3603 let orphan_channel = "a".repeat(64);
3604 let advanced = crate::db::community::advance_channel_epoch(&cid, &orphan_channel, 2, &[0x77u8; 32]).unwrap();
3605 assert!(!advanced, "no head row → head not advanced");
3606 assert_eq!(crate::db::community::held_epoch_key(&cid, &orphan_channel, 2).unwrap(), Some([0x77u8; 32]), "key still archived");
3607 }
3608
3609 #[tokio::test]
3610 async fn rotate_channel_publishes_recoverable_rekey_and_advances_own_head() {
3611 use crate::community::derive::{recipient_pseudonym, rekey_pseudonym};
3612 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
3613 let (_tmp, _guard) = init_test_db();
3614 let owner = Keys::generate();
3615 become_local(&owner); let community = saved_community_owned_by(&owner);
3617 let channel_id = community.channels[0].id;
3618 let member = Keys::generate(); let relay = MemoryRelay::new();
3620
3621 let new_epoch = rotate_channel(&relay, &community, &channel_id, &[member.public_key()], community.server_root_key.as_bytes())
3622 .await
3623 .expect("rotate");
3624 assert_eq!(new_epoch, 1);
3625
3626 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3628 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
3629
3630 let addr = rekey_pseudonym(
3633 &crate::community::ServerRootKey(*community.server_root_key.as_bytes()),
3634 &channel_id, crate::community::Epoch(1),
3635 )
3636 .to_hex();
3637 let found = relay
3638 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
3639 .await
3640 .unwrap();
3641 assert_eq!(found.len(), 1, "rekey addressable by its server-root pseudonym");
3642 let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
3643 assert_eq!(parsed.rotator, owner.public_key());
3644 assert_eq!(parsed.new_epoch, crate::community::Epoch(1));
3645 assert_eq!(parsed.prev_epoch, crate::community::Epoch(0));
3646 assert_eq!(parsed.blobs.len(), 2, "the member + me (multi-device) each get a blob");
3647
3648 let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
3650 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3651 let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
3652 let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
3653 assert_eq!(reloaded.channels[0].key.as_bytes(), &recovered, "member's recovered key == my advanced head key");
3654 }
3655
3656 #[tokio::test]
3657 async fn rotate_channel_failed_publish_leaves_head_unadvanced() {
3658 let (_tmp, _guard) = init_test_db();
3661 let owner = Keys::generate();
3662 become_local(&owner);
3663 let community = saved_community_owned_by(&owner);
3664 let member = Keys::generate();
3665 let err = rotate_channel(&FailingRelay, &community, &community.channels[0].id, &[member.public_key()], community.server_root_key.as_bytes()).await;
3666 assert!(err.is_err(), "a failed publish must propagate, not silently advance");
3667 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3668 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0), "head stays put on publish failure");
3669 }
3670
3671 fn build_rekey_chain(
3675 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::PublicKey, n: u64,
3676 ) -> (Vec<Event>, Vec<[u8; 32]>) {
3677 let chan = &community.channels[0];
3678 let scope = super::super::derive::RekeyScope::Channel(chan.id);
3679 let mut prev_key = *chan.key.as_bytes();
3680 let mut events = Vec::new();
3681 let mut keys = Vec::new();
3682 for e in 1..=n {
3683 let new_key = [e as u8; 32];
3684 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient_pk, scope, crate::community::Epoch(e), &new_key).unwrap();
3685 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prev_key);
3686 let ev = super::super::rekey::build_channel_rekey_event(
3687 &Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
3688 crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
3689 ).unwrap();
3690 events.push(ev);
3691 keys.push(new_key);
3692 prev_key = new_key;
3693 }
3694 (events, keys)
3695 }
3696
3697 #[tokio::test]
3698 async fn catch_up_steps_over_a_missing_epoch() {
3699 let (_tmp, _guard) = init_test_db();
3702 let owner = Keys::generate();
3703 let me = Keys::generate();
3704 become_local(&me);
3705 let community = saved_community_owned_by(&owner);
3706 let channel_id = community.channels[0].id;
3707 let cid = community.id.to_hex();
3708 let chan_hex = channel_id.to_hex();
3709
3710 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
3711 let relay = MemoryRelay::new();
3712 relay.inject(&events[0], &community.relays); relay.inject(&events[2], &community.relays); let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3715
3716 assert_eq!(reached, 3, "head reaches the latest present epoch, stepping over the gap");
3717 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(keys[0]));
3718 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), None, "missing epoch is a hole");
3719 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(keys[2]));
3720 }
3721
3722 #[tokio::test]
3723 async fn catch_up_recovers_a_rekey_under_a_prior_server_root() {
3724 let (_tmp, _guard) = init_test_db();
3729 let owner = Keys::generate();
3730 let me = Keys::generate();
3731 become_local(&me);
3732 let root0_community = saved_community_owned_by(&owner);
3733 let cid = root0_community.id.to_hex();
3734 let channel_id = root0_community.channels[0].id;
3735 let chan_hex = channel_id.to_hex();
3736 let scope = super::super::derive::RekeyScope::Channel(channel_id);
3737 let genesis_key = *root0_community.channels[0].key.as_bytes();
3738
3739 let root1 = [0x99u8; 32];
3741 crate::db::community::advance_server_root_epoch(&cid, 1, &root1).unwrap();
3742 let community = crate::db::community::load_community(&root0_community.id).unwrap().unwrap();
3743 assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
3744
3745 let (k1, k2) = ([0x11u8; 32], [0x22u8; 32]);
3747 let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
3748 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
3749 let ev1 = super::super::rekey::build_channel_rekey_event(
3750 &Keys::generate(), &owner, root0_community.server_root_key.as_bytes(), &channel_id,
3751 crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
3752 let blob2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &k2).unwrap();
3753 let commit1 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1);
3754 let ev2 = super::super::rekey::build_channel_rekey_event(
3755 &Keys::generate(), &owner, &root1, &channel_id,
3756 crate::community::Epoch(2), crate::community::Epoch(1), &commit1, &[blob2]).unwrap();
3757
3758 let relay = MemoryRelay::new();
3759 relay.inject(&ev1, &community.relays);
3760 relay.inject(&ev2, &community.relays);
3761
3762 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3763 assert_eq!(reached, 2, "reached the latest channel epoch across the server-root rotation");
3764 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
3765 "epoch-1 key recovered from a rekey under the PRIOR server root");
3766 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(k2));
3767 }
3768
3769 #[tokio::test]
3770 async fn catch_up_backfills_a_sub_head_gap() {
3771 let (_tmp, _guard) = init_test_db();
3774 let owner = Keys::generate();
3775 let me = Keys::generate();
3776 become_local(&me);
3777 let community = saved_community_owned_by(&owner);
3778 let cid = community.id.to_hex();
3779 let channel_id = community.channels[0].id;
3780 let chan_hex = channel_id.to_hex();
3781 let scope = super::super::derive::RekeyScope::Channel(channel_id);
3782 let genesis_key = *community.channels[0].key.as_bytes();
3783
3784 let k2 = [0x22u8; 32];
3786 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &k2).unwrap();
3787 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), None, "epoch 1 starts as a hole");
3788
3789 let k1 = [0x11u8; 32];
3791 let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
3792 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
3793 let ev1 = super::super::rekey::build_channel_rekey_event(
3794 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
3795 crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
3796 let relay = MemoryRelay::new();
3797 relay.inject(&ev1, &community.relays);
3798
3799 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
3800 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3801 assert_eq!(reached, 2, "head unchanged (gap-fill never regresses it)");
3802 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
3803 "the sub-head hole was backfilled");
3804 }
3805
3806 #[tokio::test]
3807 async fn catch_up_walks_a_chain_of_rotations_to_the_latest() {
3808 let (_tmp, _guard) = init_test_db();
3809 let owner = Keys::generate();
3810 let me = Keys::generate();
3811 become_local(&me); let community = saved_community_owned_by(&owner);
3813 let channel_id = community.channels[0].id;
3814 let cid = community.id.to_hex();
3815 let chan_hex = channel_id.to_hex();
3816
3817 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
3819 let relay = MemoryRelay::new();
3820 for ev in events.iter().rev() {
3821 relay.inject(ev, &community.relays);
3822 }
3823
3824 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3825 assert_eq!(reached, 3, "caught up to the latest epoch");
3826 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3828 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(3));
3829 assert_eq!(reloaded.channels[0].key.as_bytes(), &keys[2]);
3830 for (i, k) in keys.iter().enumerate() {
3831 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, (i + 1) as u64).unwrap(), Some(*k));
3832 }
3833 }
3834
3835 #[tokio::test]
3836 async fn catch_up_slides_across_the_window_boundary() {
3837 let (_tmp, _guard) = init_test_db();
3841 let owner = Keys::generate();
3842 let me = Keys::generate();
3843 become_local(&me);
3844 let community = saved_community_owned_by(&owner);
3845 let channel_id = community.channels[0].id;
3846 let cid = community.id.to_hex();
3847 let chan_hex = channel_id.to_hex();
3848
3849 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 70);
3850 let relay = MemoryRelay::new();
3851 for ev in &events {
3852 relay.inject(ev, &community.relays);
3853 }
3854 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3855 assert_eq!(reached, 70, "slid past the 64-epoch window boundary to the latest");
3856 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 70).unwrap(), Some(keys[69]));
3857 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 64).unwrap(), Some(keys[63]), "window-1 keys retained too");
3858 }
3859
3860 fn build_base_rekey_chain(
3866 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::PublicKey, n: u64,
3867 ) -> (Vec<Event>, Vec<[u8; 32]>) {
3868 let mut prior_root = *community.server_root_key.as_bytes();
3869 let mut events = Vec::new();
3870 let mut roots = Vec::new();
3871 for e in 1..=n {
3872 let new_root = [(e % 256) as u8; 32];
3873 let blob = super::super::rekey::build_rekey_blob(
3874 owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(e), &new_root,
3875 )
3876 .unwrap();
3877 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prior_root);
3878 events.push(super::super::rekey::build_server_root_rekey_event(
3879 &Keys::generate(), owner, &prior_root, &community.id,
3880 crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
3881 ).unwrap());
3882 roots.push(new_root);
3883 prior_root = new_root;
3884 }
3885 (events, roots)
3886 }
3887
3888 #[tokio::test]
3889 async fn catch_up_server_root_walks_a_chain_of_base_rotations() {
3890 let (_tmp, _guard) = init_test_db();
3891 let owner = Keys::generate();
3892 let me = Keys::generate();
3893 become_local(&me);
3894 let community = saved_community_owned_by(&owner);
3895 let cid = community.id.to_hex();
3896
3897 let (events, roots) = build_base_rekey_chain(&owner, &community, &me.public_key(), 3);
3898 let relay = MemoryRelay::new();
3899 for ev in events.iter().rev() {
3900 relay.inject(ev, &community.relays);
3901 }
3902 let reached = catch_up_server_root(&relay, &community).await.unwrap();
3903 assert_eq!(reached.epoch, 3, "walked the base chain to the latest epoch");
3904 assert!(!reached.removed, "a normal catch-up is not a removal");
3905 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3906 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(3));
3907 assert_eq!(reloaded.server_root_key.as_bytes(), &roots[2], "base head is the latest root");
3908 for (i, r) in roots.iter().enumerate() {
3910 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, (i + 1) as u64).unwrap(), Some(*r));
3911 }
3912 }
3913
3914 #[tokio::test]
3915 async fn catch_up_recovers_from_a_split_base_rotation_second_chunk() {
3916 let (_tmp, _guard) = init_test_db();
3920 let owner = Keys::generate();
3921 let me = Keys::generate();
3922 become_local(&me);
3923 let community = saved_community_owned_by(&owner);
3924 let genesis = *community.server_root_key.as_bytes();
3925 let new_root = [0x5Au8; 32];
3926 let scope = super::super::derive::RekeyScope::ServerRoot;
3927 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
3928 let mk = |recipient: &nostr_sdk::PublicKey| {
3929 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient, scope, crate::community::Epoch(1), &new_root).unwrap();
3930 super::super::rekey::build_server_root_rekey_event(
3931 &Keys::generate(), &owner, &genesis, &community.id,
3932 crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
3933 ).unwrap()
3934 };
3935 let relay = MemoryRelay::new();
3936 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();
3940 assert_eq!(reached.epoch, 1, "recovered the split rotation via the second chunk");
3941 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3942 assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "recovered the new root from chunk 2");
3943 }
3944
3945 #[tokio::test]
3946 async fn catch_up_converges_concurrent_refoundings_on_the_lowest_root() {
3947 let (_tmp, _guard) = init_test_db();
3951 let owner = Keys::generate();
3952 let me = Keys::generate();
3953 become_local(&me);
3954 let community = saved_community_owned_by(&owner);
3955 let genesis = *community.server_root_key.as_bytes();
3956 let scope = super::super::derive::RekeyScope::ServerRoot;
3957 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
3958 let root_lo = [0x10u8; 32];
3959 let root_hi = [0xF0u8; 32]; let mk = |root: &[u8; 32]| {
3961 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), root).unwrap();
3962 super::super::rekey::build_server_root_rekey_event(
3963 &Keys::generate(), &owner, &genesis, &community.id,
3964 crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
3965 ).unwrap()
3966 };
3967 let relay = MemoryRelay::new();
3968 relay.inject(&mk(&root_hi), &community.relays);
3970 relay.inject(&mk(&root_lo), &community.relays);
3971
3972 let reached = catch_up_server_root(&relay, &community).await.unwrap();
3973 assert_eq!(reached.epoch, 1, "advanced one epoch");
3974 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3975 assert_eq!(reloaded.server_root_key.as_bytes(), &root_lo, "converged on the LOWEST root, not the first-arrived");
3976 }
3977
3978 #[tokio::test]
3979 async fn rotate_retry_reuses_the_archived_root_no_same_epoch_fork() {
3980 let (_tmp, _guard) = init_test_db();
3985 let owner = Keys::generate();
3986 become_local(&owner);
3987 let community = saved_community_owned_by(&owner);
3988 let cid = community.id.to_hex();
3989 let relay = RekeyFailingRelay::new(); let member = Keys::generate();
3991
3992 assert!(rotate_server_root(&relay, &community, &[member.public_key()]).await.is_err(), "the rekey publish fails");
3993 let k1 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap()
3994 .expect("the new root is archived before publishing (fork-safety)");
3995 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3996 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "head not advanced on a failed publish");
3997
3998 relay.allow_rekey();
3999 rotate_server_root(&relay, &reloaded, &[member.public_key()]).await.unwrap();
4000 let k2 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap().unwrap();
4001 assert_eq!(k1, k2, "the retry REUSES the archived root — no second root for epoch 1, no fork");
4002 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4003 assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "the retry completed the rotation");
4004 assert_eq!(after.server_root_key.as_bytes(), &k1, "the committed root is the one minted on attempt 1");
4005 }
4006
4007 #[tokio::test]
4008 async fn rotate_server_root_splits_a_large_recipient_set_into_multiple_events() {
4009 let (_tmp, _guard) = init_test_db();
4011 let owner = Keys::generate();
4012 become_local(&owner);
4013 let community = saved_community_owned_by(&owner);
4014 let genesis = *community.server_root_key.as_bytes();
4015 let relay = MemoryRelay::new();
4016 let recipients: Vec<_> = (0..super::super::rekey::MAX_REKEY_BLOBS).map(|_| Keys::generate().public_key()).collect();
4018 rotate_server_root(&relay, &community, &recipients).await.unwrap();
4019 let addr = super::super::derive::base_rekey_pseudonym(&super::super::ServerRootKey(genesis), &community.id, crate::community::Epoch(1)).to_hex();
4020 let evs = relay
4021 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4022 .await
4023 .unwrap();
4024 assert_eq!(evs.len(), 2, "a >MAX_REKEY_BLOBS rotation splits into 2 events at one address");
4025 }
4026
4027 #[tokio::test]
4028 async fn catch_up_server_root_is_a_noop_with_no_rotations() {
4029 let (_tmp, _guard) = init_test_db();
4030 let owner = Keys::generate();
4031 let me = Keys::generate();
4032 become_local(&me);
4033 let community = saved_community_owned_by(&owner);
4034 let relay = MemoryRelay::new();
4035 assert_eq!(catch_up_server_root(&relay, &community).await.unwrap().epoch, 0, "no base rotations → stays at 0");
4036 }
4037
4038 #[tokio::test]
4039 async fn concurrent_refounders_converge_to_the_lowest_root() {
4040 let (_tmp, _guard) = init_test_db();
4045 let owner = Keys::generate();
4046 let me = Keys::generate();
4047 become_local(&me); let community = saved_community_owned_by(&owner);
4049 let cid = community.id.to_hex();
4050 let genesis_root = *community.server_root_key.as_bytes();
4051 let scope = super::super::derive::RekeyScope::ServerRoot;
4052
4053 let root_lo = [0x10u8; 32];
4056 let root_hi = [0x99u8; 32]; let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4058 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_lo).unwrap();
4059 let ev_lo = super::super::rekey::build_server_root_rekey_event(
4060 &Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4061
4062 let relay = MemoryRelay::new();
4063 relay.inject(&ev_lo, &community.relays);
4064
4065 crate::db::community::advance_server_root_epoch(&cid, 1, &root_hi).unwrap();
4067 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4068 assert_eq!(community.server_root_key.as_bytes(), &root_hi, "start on the higher root");
4069
4070 let out = catch_up_server_root(&relay, &community).await.unwrap();
4071 assert_eq!(out.epoch, 1, "converged in place at the same epoch (not advanced)");
4072 assert!(!out.removed);
4073 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4074 assert_eq!(after.server_root_key.as_bytes(), &root_lo, "originator converged to the lowest authorized root");
4075
4076 let _ = catch_up_server_root(&relay, &after).await.unwrap();
4078 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_key.as_bytes(), &root_lo, "no flip-flop");
4079 }
4080
4081 #[tokio::test]
4082 async fn banned_rotators_rekey_is_not_a_convergence_candidate() {
4083 let (_tmp, _guard) = init_test_db();
4088 let owner = Keys::generate();
4089 let me = Keys::generate();
4090 let banned_admin = Keys::generate();
4091 become_local(&me);
4092 let community = saved_community_owned_by(&owner);
4093 let cid = community.id.to_hex();
4094 let genesis_root = *community.server_root_key.as_bytes();
4095 let scope = super::super::derive::RekeyScope::ServerRoot;
4096
4097 let role_id = "e".repeat(64);
4099 let roster = crate::community::roles::CommunityRoles {
4100 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
4101 grants: vec![crate::community::roles::MemberGrant { member: banned_admin.public_key().to_hex(), role_ids: vec![role_id] }],
4102 };
4103 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
4104 crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
4106
4107 let root_evil = [0x01u8; 32];
4109 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4110 let blob = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
4111 let ev = super::super::rekey::build_server_root_rekey_event(
4112 &Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob]).unwrap();
4113 let relay = MemoryRelay::new();
4114 relay.inject(&ev, &community.relays);
4115
4116 let out = catch_up_server_root(&relay, &community).await.unwrap();
4119 assert_eq!(out.epoch, 0, "banned rotator's re-founding must not advance the base");
4120 assert!(!out.removed, "banned rotator's exclusion must not read as an authorized removal");
4121 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4122 assert_eq!(after.server_root_key.as_bytes(), &genesis_root, "root unchanged");
4123
4124 let parsed = super::super::rekey::open_rekey_event(&ev, &genesis_root).unwrap();
4126 assert!(apply_server_root_rekey(&community, &parsed).is_err(), "apply must refuse a banned rotator");
4127 }
4128
4129 #[tokio::test]
4130 async fn heal_abandons_a_deauthorized_root_for_the_authorized_higher_sibling() {
4131 let (_tmp, _guard) = init_test_db();
4135 let owner = Keys::generate();
4136 let me = Keys::generate();
4137 let banned_admin = Keys::generate();
4138 become_local(&me);
4139 let community = saved_community_owned_by(&owner);
4140 let cid = community.id.to_hex();
4141 let genesis_root = *community.server_root_key.as_bytes();
4142 let scope = super::super::derive::RekeyScope::ServerRoot;
4143 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4144
4145 let root_evil = [0x01u8; 32];
4148 let root_owner = [0x77u8; 32];
4149 let blob_evil = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
4150 let ev_evil = super::super::rekey::build_server_root_rekey_event(
4151 &Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_evil]).unwrap();
4152 let blob_owner = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_owner).unwrap();
4153 let ev_owner = super::super::rekey::build_server_root_rekey_event(
4154 &Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_owner]).unwrap();
4155 let relay = MemoryRelay::new();
4156 relay.inject(&ev_evil, &community.relays);
4157 relay.inject(&ev_owner, &community.relays);
4158
4159 crate::db::community::advance_server_root_epoch(&cid, 1, &root_evil).unwrap();
4161 crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
4162 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4163 assert_eq!(community.server_root_key.as_bytes(), &root_evil, "start partitioned on the attacker's root");
4164
4165 let out = catch_up_server_root(&relay, &community).await.unwrap();
4166 assert_eq!(out.epoch, 1);
4167 assert!(!out.removed);
4168 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4169 assert_eq!(after.server_root_key.as_bytes(), &root_owner,
4170 "heal must abandon the deauthorized root and adopt the owner's higher sibling");
4171
4172 let _ = catch_up_server_root(&relay, &after).await.unwrap();
4174 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");
4175 }
4176
4177 #[tokio::test]
4178 async fn concurrent_channel_rekeyers_converge_to_the_lowest_key() {
4179 let (_tmp, _guard) = init_test_db();
4184 let owner = Keys::generate();
4185 let me = Keys::generate();
4186 become_local(&me); let community = saved_community_owned_by(&owner);
4188 let cid = community.id.to_hex();
4189 let channel_id = community.channels[0].id;
4190 let chan_hex = channel_id.to_hex();
4191 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4192 let genesis_key = *community.channels[0].key.as_bytes();
4193 let root = *community.server_root_key.as_bytes();
4194 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4195
4196 let key_lo = [0x10u8; 32];
4198 let key_hi = [0x99u8; 32];
4199 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4200 let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4201 let ev_lo = super::super::rekey::build_channel_rekey_event(
4202 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4203 let ev_hi = super::super::rekey::build_channel_rekey_event(
4204 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4205
4206 let relay = MemoryRelay::new();
4207 relay.inject(&ev_hi, &community.relays); relay.inject(&ev_lo, &community.relays);
4209
4210 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4215 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4217 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4218
4219 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4220 assert_eq!(reached, 1, "converged in place at the same channel epoch");
4221 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
4222 "adopted the lowest delivered key regardless of relay order");
4223
4224 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4226 let _ = catch_up_channel_rekeys(&relay, &after, &channel_id).await.unwrap();
4227 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo), "no flip-flop");
4228 }
4229
4230 #[tokio::test]
4231 async fn concurrent_channel_rekeyers_converge_when_i_authored_the_losing_fork() {
4232 let (_tmp, _guard) = init_test_db();
4237 let owner = Keys::generate();
4238 let me = Keys::generate(); become_local(&me);
4240 let community = saved_community_owned_by(&owner);
4241 let cid = community.id.to_hex();
4242 let channel_id = community.channels[0].id;
4243 let chan_hex = channel_id.to_hex();
4244 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4245 let genesis_key = *community.channels[0].key.as_bytes();
4246 let root = *community.server_root_key.as_bytes();
4247 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4248
4249 let role_id = "d".repeat(64);
4253 let roster = crate::community::roles::CommunityRoles {
4254 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
4255 grants: vec![crate::community::roles::MemberGrant { member: me.public_key().to_hex(), role_ids: vec![role_id] }],
4256 };
4257 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
4258
4259 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();
4263 let ev_lo = super::super::rekey::build_channel_rekey_event(
4264 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4265 let blob_hi = super::super::rekey::build_rekey_blob(me.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4267 let ev_hi = super::super::rekey::build_channel_rekey_event(
4268 &Keys::generate(), &me, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4269
4270 let relay = MemoryRelay::new();
4271 relay.inject(&ev_hi, &community.relays);
4272 relay.inject(&ev_lo, &community.relays);
4273
4274 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4276 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4278 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4279
4280 let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4281 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
4282 "I authored the losing fork but must converge DOWN to the owner's lower key");
4283 }
4284
4285 #[tokio::test]
4286 async fn reorg_through_a_fork_heals_the_forked_past_epoch() {
4287 let (_tmp, _guard) = init_test_db();
4292 let owner = Keys::generate();
4293 let me = Keys::generate();
4294 become_local(&me);
4295 let community = saved_community_owned_by(&owner);
4296 let cid = community.id.to_hex();
4297 let channel_id = community.channels[0].id;
4298 let chan_hex = channel_id.to_hex();
4299 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4300 let genesis_key = *community.channels[0].key.as_bytes();
4301 let root = *community.server_root_key.as_bytes();
4302 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4303
4304 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();
4309 let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
4310 let ev_lo1 = super::super::rekey::build_channel_rekey_event(
4311 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
4312 let ev_hi1 = super::super::rekey::build_channel_rekey_event(
4313 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
4314 let commit1_win = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &key_lo1);
4316 let blob_e2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &key_e2).unwrap();
4317 let ev_e2 = super::super::rekey::build_channel_rekey_event(
4318 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit1_win, &[blob_e2]).unwrap();
4319
4320 let relay = MemoryRelay::new();
4321 relay.inject(&ev_lo1, &community.relays);
4322 relay.inject(&ev_hi1, &community.relays);
4323 relay.inject(&ev_e2, &community.relays);
4324
4325 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4327 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
4329 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4330
4331 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4332 assert_eq!(reached, 2, "reorged forward to the head epoch");
4333 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch adopted");
4334 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
4335 "the FORKED past epoch re-converged to the lowest sibling (its messages become readable)");
4336 }
4337
4338 #[tokio::test]
4339 async fn window_heal_converges_an_already_reorged_past_fork() {
4340 let (_tmp, _guard) = init_test_db();
4345 let owner = Keys::generate();
4346 let me = Keys::generate();
4347 become_local(&me);
4348 let community = saved_community_owned_by(&owner);
4349 let cid = community.id.to_hex();
4350 let channel_id = community.channels[0].id;
4351 let chan_hex = channel_id.to_hex();
4352 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4353 let genesis_key = *community.channels[0].key.as_bytes();
4354 let root = *community.server_root_key.as_bytes();
4355 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4356
4357 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();
4361 let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
4362 let ev_lo1 = super::super::rekey::build_channel_rekey_event(
4363 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
4364 let ev_hi1 = super::super::rekey::build_channel_rekey_event(
4365 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
4366
4367 let relay = MemoryRelay::new();
4368 relay.inject(&ev_lo1, &community.relays);
4369 relay.inject(&ev_hi1, &community.relays);
4370 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4374 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
4376 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &key_e2).unwrap();
4377 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4378
4379 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4380 assert_eq!(reached, 2, "head unchanged (no new rekey to apply)");
4381 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch untouched");
4382 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
4383 "the already-forked past epoch re-converged to the lowest sibling via the window heal (no in-sync reorg)");
4384 }
4385
4386 #[tokio::test]
4387 async fn channel_heal_cannot_converge_to_a_key_i_was_not_given() {
4388 let (_tmp, _guard) = init_test_db();
4395 let owner = Keys::generate();
4396 let me = Keys::generate();
4397 become_local(&me);
4398 let community = saved_community_owned_by(&owner);
4399 let cid = community.id.to_hex();
4400 let channel_id = community.channels[0].id;
4401 let chan_hex = channel_id.to_hex();
4402 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4403 let genesis_key = *community.channels[0].key.as_bytes();
4404 let root = *community.server_root_key.as_bytes();
4405 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4406
4407 let key_lo = [0x10u8; 32]; let key_hi = [0x99u8; 32]; let other = Keys::generate();
4411 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4412 let ev_lo = super::super::rekey::build_channel_rekey_event(
4413 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4414 let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4416 let ev_hi = super::super::rekey::build_channel_rekey_event(
4417 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4418
4419 let relay = MemoryRelay::new();
4420 relay.inject(&ev_lo, &community.relays);
4421 relay.inject(&ev_hi, &community.relays);
4422 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4423 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4424 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4425
4426 let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4427 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_hi),
4429 "excluded from the winning rekey ⇒ cannot converge");
4430 }
4431
4432 #[tokio::test]
4433 async fn refounding_channel_rekey_is_sealed_under_the_prior_root() {
4434 let (_tmp, _guard) = init_test_db();
4439 let owner = Keys::generate();
4440 become_local(&owner); let community = saved_community_owned_by(&owner);
4442 let channel_id = community.channels[0].id;
4443 let prior_root = [0x11u8; 32]; let relay = MemoryRelay::new();
4446 rotate_channel(&relay, &community, &channel_id, &[owner.public_key()], &prior_root).await.unwrap();
4447
4448 let z = super::super::derive::rekey_pseudonym(&crate::community::ServerRootKey(prior_root), &channel_id, crate::community::Epoch(1)).to_hex();
4450 let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![z], ..Default::default() };
4451 let evs = relay.fetch(&q, &community.relays).await.unwrap();
4452 assert_eq!(evs.len(), 1, "channel rekey is addressed at the PRIOR-root pseudonym");
4453 assert!(super::super::rekey::open_rekey_event(&evs[0], &prior_root).is_ok(),
4455 "opens under the prior (shared) root every retained member still holds");
4456 assert!(super::super::rekey::open_rekey_event(&evs[0], community.server_root_key.as_bytes()).is_err(),
4457 "does NOT open under the current/new root (which a base-fork loser would have dropped)");
4458 }
4459
4460 #[tokio::test]
4461 async fn apply_channel_rekey_converges_past_a_divergent_prior_epoch() {
4462 let (_tmp, _guard) = init_test_db();
4467 let owner = Keys::generate();
4468 let me = Keys::generate();
4469 become_local(&me);
4470 let community = saved_community_owned_by(&owner);
4471 let cid = community.id.to_hex();
4472 let channel_id = community.channels[0].id;
4473 let chan_hex = channel_id.to_hex();
4474 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4475 let root = *community.server_root_key.as_bytes();
4476
4477 let my_fork_key = [0xAAu8; 32];
4479 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &my_fork_key).unwrap();
4480
4481 let winner_epoch1 = [0xBBu8; 32];
4483 let new_key = [0x22u8; 32];
4484 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &winner_epoch1);
4485 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &new_key).unwrap();
4486 let ev = super::super::rekey::build_channel_rekey_event(
4487 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit, &[blob]).unwrap();
4488 let parsed = super::super::rekey::open_rekey_event(&ev, &root).unwrap();
4489
4490 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
4491 assert!(matches!(outcome, RekeyOutcome::Applied { head_advanced: true }),
4492 "must converge forward past the divergent prior epoch, got {outcome:?}");
4493 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(new_key),
4494 "adopted the winner's epoch-2 key");
4495 }
4496
4497 #[tokio::test]
4498 async fn catch_up_server_root_stops_when_removed_from_base() {
4499 let (_tmp, _guard) = init_test_db();
4502 let owner = Keys::generate();
4503 let me = Keys::generate();
4504 become_local(&me);
4505 let community = saved_community_owned_by(&owner);
4506 let scope = super::super::derive::RekeyScope::ServerRoot;
4507 let relay = MemoryRelay::new();
4508
4509 let root1 = [0x11u8; 32];
4511 let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root1).unwrap();
4512 let e1 = super::super::rekey::build_server_root_rekey_event(
4513 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
4514 crate::community::Epoch(1), crate::community::Epoch(0),
4515 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), community.server_root_key.as_bytes()), &[b1],
4516 ).unwrap();
4517 let other = Keys::generate();
4519 let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
4520 let e2 = super::super::rekey::build_server_root_rekey_event(
4521 &Keys::generate(), &owner, &root1, &community.id,
4522 crate::community::Epoch(2), crate::community::Epoch(1),
4523 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &root1), &[b2],
4524 ).unwrap();
4525 relay.inject(&e1, &community.relays);
4526 relay.inject(&e2, &community.relays);
4527
4528 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4529 assert_eq!(reached.epoch, 1, "stops at the last base epoch I was a recipient of");
4530 assert!(reached.removed, "excluded by an AUTHORIZED (owner) base rotation → flagged removed so the caller erases");
4531 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4532 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
4533 }
4534
4535 #[tokio::test]
4536 async fn catch_up_is_a_noop_with_no_rotations() {
4537 let (_tmp, _guard) = init_test_db();
4538 let owner = Keys::generate();
4539 let me = Keys::generate();
4540 become_local(&me);
4541 let community = saved_community_owned_by(&owner);
4542 let relay = MemoryRelay::new(); let reached = catch_up_channel_rekeys(&relay, &community, &community.channels[0].id).await.unwrap();
4544 assert_eq!(reached, 0, "no rotations → stays at the held epoch");
4545 }
4546
4547 #[tokio::test]
4548 async fn catch_up_stops_when_removed_midway() {
4549 let (_tmp, _guard) = init_test_db();
4552 let owner = Keys::generate();
4553 let me = Keys::generate();
4554 become_local(&me);
4555 let community = saved_community_owned_by(&owner);
4556 let channel_id = community.channels[0].id;
4557 let chan = &community.channels[0];
4558 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4559 let relay = MemoryRelay::new();
4560
4561 let k1 = [0x11u8; 32];
4563 let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
4564 let e1 = super::super::rekey::build_channel_rekey_event(
4565 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4566 crate::community::Epoch(1), crate::community::Epoch(0),
4567 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes()), &[b1],
4568 ).unwrap();
4569 let other = Keys::generate();
4571 let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
4572 let e2 = super::super::rekey::build_channel_rekey_event(
4573 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4574 crate::community::Epoch(2), crate::community::Epoch(1),
4575 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1), &[b2],
4576 ).unwrap();
4577 relay.inject(&e1, &community.relays);
4578 relay.inject(&e2, &community.relays);
4579
4580 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4581 assert_eq!(reached, 1, "stops at the last epoch I was a recipient of");
4582 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4583 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
4584 }
4585
4586 #[tokio::test]
4587 async fn rotate_channel_rejects_unauthorized() {
4588 let (_tmp, _guard) = init_test_db();
4589 let owner = Keys::generate();
4590 let rogue = Keys::generate();
4591 become_local(&rogue); let community = saved_community_owned_by(&owner);
4593 let relay = MemoryRelay::new();
4594 assert!(
4595 rotate_channel(&relay, &community, &community.channels[0].id, &[], community.server_root_key.as_bytes()).await.is_err(),
4596 "a non-authorized member cannot rotate"
4597 );
4598 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4600 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
4601 }
4602
4603 #[tokio::test]
4606 async fn rotate_server_root_publishes_recoverable_rekey_and_advances_base() {
4607 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
4608 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
4609 let (_tmp, _guard) = init_test_db();
4610 let owner = Keys::generate();
4611 become_local(&owner); let community = saved_community_owned_by(&owner);
4613 let genesis_root = *community.server_root_key.as_bytes();
4614 let member = Keys::generate();
4615 let relay = MemoryRelay::new();
4616
4617 let new_epoch = rotate_server_root(&relay, &community, &[member.public_key()]).await.expect("rotate base");
4618 assert_eq!(new_epoch, 1);
4619
4620 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4622 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
4623 assert_ne!(reloaded.server_root_key.as_bytes(), &genesis_root, "base root is fresh-random, not the genesis");
4624
4625 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
4627 let found = relay
4628 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4629 .await
4630 .unwrap();
4631 assert_eq!(found.len(), 1, "base rekey addressable by its prior-root pseudonym");
4632 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
4633 assert!(matches!(parsed.scope, crate::community::derive::RekeyScope::ServerRoot));
4634 assert_eq!(parsed.rotator, owner.public_key());
4635 assert_eq!(parsed.blobs.len(), 2, "member + me (multi-device)");
4636
4637 let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
4639 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
4640 let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
4641 let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
4642 assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "member's recovered root == owner's advanced base head");
4643 }
4644
4645 #[tokio::test]
4646 async fn rotate_server_root_failed_publish_leaves_base_unadvanced() {
4647 let (_tmp, _guard) = init_test_db();
4648 let owner = Keys::generate();
4649 become_local(&owner);
4650 let community = saved_community_owned_by(&owner);
4651 let member = Keys::generate();
4652 assert!(rotate_server_root(&FailingRelay, &community, &[member.public_key()]).await.is_err());
4653 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4654 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head stays put on publish failure");
4655 }
4656
4657 #[tokio::test]
4658 async fn rotate_server_root_dedups_self_in_recipients() {
4659 use crate::community::rekey::open_rekey_event;
4661 let (_tmp, _guard) = init_test_db();
4662 let owner = Keys::generate();
4663 become_local(&owner);
4664 let community = saved_community_owned_by(&owner);
4665 let relay = MemoryRelay::new();
4666 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
4667 let addr = crate::community::derive::base_rekey_pseudonym(
4668 &crate::community::ServerRootKey(*community.server_root_key.as_bytes()), &community.id, crate::community::Epoch(1),
4669 )
4670 .to_hex();
4671 let found = relay
4672 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4673 .await
4674 .unwrap();
4675 let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
4676 assert_eq!(parsed.blobs.len(), 1, "self listed in recipients yields exactly one blob, not two");
4677 }
4678
4679 #[tokio::test]
4680 async fn rotate_server_root_rejects_unauthorized() {
4681 let (_tmp, _guard) = init_test_db();
4682 let owner = Keys::generate();
4683 let rogue = Keys::generate();
4684 become_local(&rogue); let community = saved_community_owned_by(&owner);
4686 let relay = MemoryRelay::new();
4687 assert!(rotate_server_root(&relay, &community, &[]).await.is_err(), "a non-BAN member cannot rotate the base");
4688 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4689 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0));
4690 }
4691
4692 #[tokio::test]
4693 async fn rotate_server_root_reanchors_the_control_plane_to_the_new_epoch() {
4694 let (_tmp, _guard) = init_test_db();
4697 let relay = MemoryRelay::new();
4698 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4701 let cid = community.id.to_hex();
4702 assert_eq!(crate::db::community::edition_head_entity_ids(&cid).unwrap().len(), 3);
4703
4704 let member = Keys::generate();
4705 assert_eq!(rotate_server_root(&relay, &community, &[member.public_key()]).await.unwrap(), 1);
4706 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4707 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "base head advanced");
4708
4709 let z = crate::community::roster::control_pseudonym(&reloaded.server_root_key, &community.id, crate::community::Epoch(1));
4711 let evs = relay
4712 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays)
4713 .await
4714 .unwrap();
4715 let inners: Vec<_> = evs
4716 .iter()
4717 .filter_map(|o| crate::community::roster::open_control_edition(o, &reloaded.server_root_key).ok())
4718 .collect();
4719 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
4720 assert!(!folded.roles.roles.is_empty(), "control plane re-anchored at the new epoch as part of the rotation");
4721 }
4722
4723 #[tokio::test]
4724 async fn admin_refounding_carries_heads_verbatim_preserving_owner_and_peer_roles() {
4725 use crate::community::roles::Permissions;
4729 let (_tmp, _guard) = init_test_db();
4730 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
4731 let owner_hex = owner.public_key().to_hex();
4732 let relay = MemoryRelay::new();
4733 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4734 let cid = community.id.to_hex();
4735 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
4736
4737 let alice = Keys::generate();
4739 let bob = Keys::generate();
4740 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
4741 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
4742 let _ = fetch_and_apply_control(&relay, &community).await;
4743 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4744
4745 let mut edited = community.clone();
4748 edited.name = "HQ renamed".into();
4749 republish_community_metadata(&relay, &edited).await.unwrap();
4750 let _ = fetch_and_apply_control(&relay, &community).await;
4751 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4752 assert!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0 >= 2, "GroupRoot now above v1");
4753
4754 become_local(&alice);
4756 let new_epoch = rotate_server_root(&relay, &community, &[owner.public_key(), bob.public_key()]).await.unwrap();
4757 assert_eq!(new_epoch, 1);
4758 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4759 assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
4760
4761 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(1));
4763 let evs = relay.fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays).await.unwrap();
4764 let inners: Vec<_> = evs.iter().filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok()).collect();
4765 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
4766 let authed = crate::community::roster::authorize_delegation(&folded, Some(&owner_hex));
4767 assert!(authed.is_authorized(&alice.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "alice (re-founder) still admin");
4768 assert!(authed.is_authorized(&bob.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "bob (peer admin) NOT demoted by alice's re-founding");
4769 let new_owner = folded.root_meta.as_ref().and_then(|m| m.owner_attestation.as_ref())
4770 .and_then(|j| Event::from_json(j).ok()).map(|e| e.pubkey.to_hex());
4771 assert_eq!(new_owner.as_deref(), Some(owner_hex.as_str()), "owner deed carried verbatim — ownership intact after an admin re-founding");
4772 assert_eq!(folded.root_meta.as_ref().map(|m| m.name.as_str()), Some("HQ renamed"),
4773 "the >v1 GroupRoot head carried verbatim (content preserved across the re-founding)");
4774 let mut per_entity: std::collections::HashMap<[u8; 32], usize> = std::collections::HashMap::new();
4776 for i in &inners {
4777 if let Ok(p) = crate::community::edition::parse_edition_inner(i) { *per_entity.entry(p.entity_id).or_default() += 1; }
4778 }
4779 assert!(per_entity.values().all(|&c| c == 1), "one edition per entity at the new epoch (compacted)");
4780 }
4781
4782 #[tokio::test]
4786 async fn admin_write_blocked_when_isolated() {
4787 let (_tmp, _guard) = init_test_db();
4788 let me = Keys::generate();
4789 become_local(&me);
4790 let community = saved_community_owned_by(&me);
4791 let cid = community.id.to_hex();
4792 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[1u8; 32], &[1u8; 32]).unwrap();
4794 crate::db::community::set_read_cut_target_epoch(&cid, 1).unwrap();
4795 let err = reseal_base_to_observed(&FailingRelay, &community).await.unwrap_err();
4797 assert!(err.contains("offline") || err.contains("can't reach any relay"),
4798 "isolated admin write must fail closed, got: {err}");
4799 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
4801 crate::community::Epoch(0), "no rotation while isolated");
4802 }
4803
4804 #[tokio::test]
4807 async fn refounding_rotates_channel_keys_too() {
4808 let (_tmp, _guard) = init_test_db();
4809 let relay = MemoryRelay::new();
4810 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4811 let channel_id = community.channels[0].id;
4812 assert_eq!(community.channels[0].epoch, crate::community::Epoch(0));
4813 assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
4814
4815 run_read_cut(&relay, &community, true).await.unwrap();
4816
4817 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4818 assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "base rotated");
4819 let ch = after.channels.iter().find(|c| c.id == channel_id).unwrap();
4820 assert_eq!(ch.epoch, crate::community::Epoch(1), "channel key rotated too (O2)");
4821 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&community.id.to_hex(), &channel_id.to_hex()).unwrap(),
4822 1, "channel marked rekeyed for the new base epoch");
4823 assert!(!crate::db::community::get_read_cut_pending(&community.id.to_hex()).unwrap(),
4824 "a complete read-cut clears the pending flag");
4825 }
4826
4827 #[tokio::test]
4832 async fn read_cut_resumes_without_double_base_rotation_after_channel_failure() {
4833 struct ChannelRekeyFails {
4837 inner: MemoryRelay,
4838 rekeys: std::sync::atomic::AtomicUsize,
4839 fail_channel: std::sync::atomic::AtomicBool,
4840 }
4841 #[async_trait::async_trait]
4842 impl Transport for ChannelRekeyFails {
4843 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
4844 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
4845 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
4846 let n = self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4847 if n >= 1 && self.fail_channel.load(std::sync::atomic::Ordering::Relaxed) {
4848 return Err("channel rekey relay down".into());
4849 }
4850 }
4851 self.inner.publish_durable(e, r).await
4852 }
4853 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
4854 }
4855 let (_tmp, _guard) = init_test_db();
4856 let relay = ChannelRekeyFails {
4857 inner: MemoryRelay::new(),
4858 rekeys: std::sync::atomic::AtomicUsize::new(0),
4859 fail_channel: std::sync::atomic::AtomicBool::new(true),
4860 };
4861 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4862 let channel_id = community.channels[0].id;
4863 let cid = community.id.to_hex();
4864 let ch_hex = channel_id.to_hex();
4865
4866 assert!(run_read_cut(&relay, &community, true).await.is_err(), "the channel failure surfaces an error");
4868 let mid = crate::db::community::load_community(&community.id).unwrap().unwrap();
4869 assert_eq!(mid.server_root_epoch, crate::community::Epoch(1), "base advanced exactly once");
4870 assert_eq!(mid.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(0),
4871 "channel NOT rotated (its rekey failed)");
4872 assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "cut left pending after the failure");
4873 assert_eq!(crate::db::community::get_read_cut_target_epoch(&cid).unwrap(), 1, "target recorded durably");
4874 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 0,
4875 "channel not yet marked for this cut");
4876
4877 relay.fail_channel.store(false, std::sync::atomic::Ordering::Relaxed);
4879 retry_pending_read_cut(&relay, &mid).await.unwrap();
4880 let done = crate::db::community::load_community(&community.id).unwrap().unwrap();
4881 assert_eq!(done.server_root_epoch, crate::community::Epoch(1),
4882 "base NOT rotated again — resumed at the same epoch (no double base rotation)");
4883 assert_eq!(done.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(1),
4884 "the un-rotated channel finished on resume");
4885 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 1,
4886 "channel marked rekeyed for the cut epoch");
4887 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the resume completes");
4888 }
4889
4890 #[tokio::test]
4891 async fn rotate_server_root_aborts_when_the_snapshot_does_not_land() {
4892 struct ControlPublishFails { inner: MemoryRelay, fail: std::sync::atomic::AtomicBool }
4897 #[async_trait::async_trait]
4898 impl Transport for ControlPublishFails {
4899 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
4900 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
4901 if self.fail.load(std::sync::atomic::Ordering::Relaxed) && e.kind.as_u16() == event_kind::COMMUNITY_CONTROL {
4902 return Err("control relay down".into());
4903 }
4904 self.inner.publish_durable(e, r).await
4905 }
4906 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
4907 }
4908 let (_tmp, _guard) = init_test_db();
4909 let relay = ControlPublishFails { inner: MemoryRelay::new(), fail: std::sync::atomic::AtomicBool::new(false) };
4910 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4912 relay.fail.store(true, std::sync::atomic::Ordering::Relaxed);
4913
4914 assert!(
4915 rotate_server_root(&relay, &community, &[]).await.is_err(),
4916 "a snapshot whose editions can't be re-published must abort the rotation"
4917 );
4918 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4919 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced when the snapshot doesn't land");
4920 }
4921
4922 #[tokio::test]
4923 async fn acquire_before_commit_a_reanchor_fetch_miss_publishes_no_base_rekey() {
4924 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4929 struct ReanchorFetchEmpty { inner: MemoryRelay, drop_control: AtomicBool, base_rekeys: AtomicUsize }
4930 #[async_trait::async_trait]
4931 impl Transport for ReanchorFetchEmpty {
4932 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
4933 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
4934 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
4935 self.base_rekeys.fetch_add(1, Ordering::Relaxed);
4936 }
4937 self.inner.publish_durable(e, r).await
4938 }
4939 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
4940 if self.drop_control.load(Ordering::Relaxed) && q.kinds.iter().any(|k| *k == event_kind::COMMUNITY_CONTROL) {
4941 return Ok(vec![]); }
4943 self.inner.fetch(q, r).await
4944 }
4945 }
4946 let (_tmp, _guard) = init_test_db();
4947 let relay = ReanchorFetchEmpty { inner: MemoryRelay::new(), drop_control: AtomicBool::new(false), base_rekeys: AtomicUsize::new(0) };
4948 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4949 relay.drop_control.store(true, Ordering::Relaxed);
4950
4951 assert!(rotate_server_root(&relay, &community, &[]).await.is_err(),
4952 "a re-anchor fetch miss must abort the rotation");
4953 assert_eq!(relay.base_rekeys.load(Ordering::Relaxed), 0,
4954 "the base rekey must NOT be published when the pre-publish fetch gate trips (acquire-before-commit)");
4955 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4956 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced");
4957 }
4958
4959 #[tokio::test]
4962 async fn reanchor_carries_role_and_grant_to_the_new_epoch_under_the_new_root() {
4963 let (_tmp, _guard) = init_test_db();
4964 let relay = MemoryRelay::new();
4965 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4967 let cid = community.id.to_hex();
4968 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
4969 let member = Keys::generate();
4970 set_member_grant(&relay, &community, &member.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
4972 let _ = fetch_and_apply_control(&relay, &community).await;
4973 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4974
4975 let new_root = [0x99u8; 32];
4977 let snap = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
4978 assert!(snap.iter().all(|e| e.published), "every snapshot edition published");
4979 assert_eq!(snap.len(), 4, "GroupRoot + channel + Admin role + grant compacted to v1");
4980
4981 let new_z = crate::community::roster::control_pseudonym(
4983 &crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
4984 );
4985 let after = relay
4986 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
4987 .await
4988 .unwrap();
4989 let inners: Vec<_> = after
4990 .iter()
4991 .filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
4992 .collect();
4993 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
4994 assert!(!folded.roles.roles.is_empty(), "Admin role reachable at the new epoch");
4995 assert!(
4996 folded.roles.grants.iter().any(|g| g.member == member.public_key().to_hex()),
4997 "grant carried to the new epoch under the new root"
4998 );
4999 }
5000
5001 #[tokio::test]
5002 async fn grant_after_a_rekey_survives_the_fold_at_the_new_epoch() {
5003 use crate::community::roles::Permissions;
5009 let (_tmp, _guard) = init_test_db();
5010 let relay = MemoryRelay::new();
5011 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5012 let cid = community.id.to_hex();
5013 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5014 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5015
5016 rotate_server_root(&relay, &community, &[owner.public_key()]).await.expect("rotate base");
5018 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5019 assert_eq!(community.server_root_epoch, crate::community::Epoch(1), "advanced to the new epoch");
5020
5021 let alice = "aa".repeat(32);
5023 set_member_grant(&relay, &community, &alice, vec![admin_role_id]).await.unwrap();
5024
5025 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5027 assert!(
5028 roster.has_permission(&alice, Permissions::BAN),
5029 "post-rekey grant survives — Alice is Admin at the new epoch (pre-fix: dropped, role unreachable)"
5030 );
5031 assert_eq!(roster.highest_position(&alice), Some(1));
5032 }
5033
5034 #[tokio::test]
5038 async fn demote_re_asserts_the_demoted_members_metadata_head() {
5039 let (_tmp, _guard) = init_test_db();
5040 let relay = MemoryRelay::new();
5041 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5042 let cid = community.id.to_hex();
5043 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5044 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5045 let alice = Keys::generate();
5046 let alice_hex = alice.public_key().to_hex();
5047
5048 set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
5049 become_local(&alice);
5051 let mut as_alice = crate::db::community::load_community(&community.id).unwrap().unwrap();
5052 as_alice.name = "Alice's HQ".into();
5053 republish_community_metadata(&relay, &as_alice).await.unwrap();
5054 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5055 assert_eq!(
5056 fetch_control_folded(&relay, &community).await.unwrap().root_author.map(|a| a.to_hex()),
5057 Some(alice_hex.clone()), "alice heads the GroupRoot after her edit",
5058 );
5059
5060 become_local(&owner);
5062 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5063 set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
5064
5065 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5066 let folded = fetch_control_folded(&relay, &community).await.unwrap();
5067 assert_eq!(folded.root_author.map(|a| a.to_hex()), Some(owner.public_key().to_hex()),
5068 "the demote re-asserted the GroupRoot under the owner");
5069 assert_eq!(folded.root_meta.as_ref().unwrap().name, "Alice's HQ",
5070 "the re-assert preserves the demoted member's content");
5071 }
5072
5073 #[tokio::test]
5076 async fn demote_skips_reassert_when_member_does_not_head() {
5077 let (_tmp, _guard) = init_test_db();
5078 let relay = MemoryRelay::new();
5079 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5080 let cid = community.id.to_hex();
5081 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5082 let alice = Keys::generate();
5083 let alice_hex = alice.public_key().to_hex();
5084
5085 set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
5086 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
5088 c.name = "Owner's HQ".into();
5089 republish_community_metadata(&relay, &c).await.unwrap();
5090 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5091 let before = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
5092
5093 set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
5094 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5095 let after = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
5096 assert_eq!(after, before, "no re-assert published — the demoted member didn't head the GroupRoot");
5097 }
5098
5099 #[tokio::test]
5100 async fn reanchor_carries_the_banlist_edition_to_the_new_epoch() {
5101 let (_tmp, _guard) = init_test_db();
5104 let relay = MemoryRelay::new();
5105 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5106 let carol = "cc".repeat(32);
5107 publish_banlist(&relay, &community, &[carol.clone()]).await.unwrap();
5109 let _ = fetch_and_apply_control(&relay, &community).await;
5110 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5111
5112 let new_root = [0x99u8; 32];
5114 let n = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
5115 assert!(n.iter().all(|e| e.published), "every snapshot edition published");
5116 assert_eq!(n.len(), 4, "GroupRoot + channel + Admin role + banlist compacted to v1");
5117
5118 let new_z = crate::community::roster::control_pseudonym(
5120 &crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
5121 );
5122 let after = relay
5123 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
5124 .await
5125 .unwrap();
5126 let inners: Vec<_> = after
5127 .iter()
5128 .filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
5129 .collect();
5130 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
5131 assert_eq!(folded.banned, vec![carol], "banlist reachable at the new epoch under the new root");
5132 }
5133
5134 fn owner_base_rekey(
5139 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::PublicKey, new_epoch: u64, new_root: &[u8; 32],
5140 ) -> super::super::rekey::ParsedRekey {
5141 let prev = community.server_root_epoch.0;
5142 let blob = super::super::rekey::build_rekey_blob(
5143 owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(new_epoch), new_root,
5144 )
5145 .unwrap();
5146 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(prev), community.server_root_key.as_bytes());
5147 let outer = super::super::rekey::build_server_root_rekey_event(
5148 &Keys::generate(), owner, community.server_root_key.as_bytes(), &community.id,
5149 crate::community::Epoch(new_epoch), crate::community::Epoch(prev), &commit, &[blob],
5150 )
5151 .unwrap();
5152 super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
5153 }
5154
5155 #[test]
5156 fn apply_server_root_rekey_recovers_new_root_and_advances_base() {
5157 let (_tmp, _guard) = init_test_db();
5158 let owner = Keys::generate();
5159 let me = Keys::generate();
5160 become_local(&me);
5161 let community = saved_community_owned_by(&owner);
5162 let cid = community.id.to_hex();
5163 let new_root = [0xCDu8; 32];
5164
5165 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &new_root);
5166 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5167
5168 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5169 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
5170 assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "base head advanced to the new root");
5171 assert!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().is_some());
5173 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(), Some(new_root));
5174 }
5175
5176 #[test]
5177 fn apply_server_root_rekey_not_a_recipient_leaves_base_unchanged() {
5178 let (_tmp, _guard) = init_test_db();
5179 let owner = Keys::generate();
5180 let me = Keys::generate();
5181 become_local(&me);
5182 let community = saved_community_owned_by(&owner);
5183 let other = Keys::generate(); let parsed = owner_base_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
5185 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
5186 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5187 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "removed-from-base member's head unchanged");
5188 }
5189
5190 #[test]
5191 fn apply_server_root_rekey_rejects_rotator_without_ban() {
5192 let (_tmp, _guard) = init_test_db();
5193 let owner = Keys::generate();
5194 let me = Keys::generate();
5195 become_local(&me);
5196 let community = saved_community_owned_by(&owner);
5197 let rogue = Keys::generate();
5199 let parsed = owner_base_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
5200 assert!(apply_server_root_rekey(&community, &parsed).is_err(), "unauthorized base rotation rejected");
5201 }
5202
5203 #[test]
5204 fn apply_server_root_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
5205 let (_tmp, _guard) = init_test_db();
5210 let owner = Keys::generate();
5211 let me = Keys::generate();
5212 become_local(&me);
5213 let community = saved_community_owned_by(&owner);
5214 let blob = super::super::rekey::build_rekey_blob(
5215 owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(1), &[0x33u8; 32],
5216 )
5217 .unwrap();
5218 let bad = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
5220 let outer = super::super::rekey::build_server_root_rekey_event(
5221 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
5222 crate::community::Epoch(1), crate::community::Epoch(0), &bad, &[blob],
5223 )
5224 .unwrap();
5225 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5226 let outcome = apply_server_root_rekey(&community, &parsed);
5227 assert!(
5228 matches!(outcome, Ok(RekeyOutcome::Applied { .. })),
5229 "an authorized base chain must be adopted (reorg), not rejected as foreign; got {outcome:?}"
5230 );
5231 }
5232
5233 #[test]
5234 fn apply_server_root_rekey_catchup_archives_without_regressing_base_head() {
5235 let (_tmp, _guard) = init_test_db();
5238 let owner = Keys::generate();
5239 let me = Keys::generate();
5240 become_local(&me);
5241 let community = saved_community_owned_by(&owner);
5242 let cid = community.id.to_hex();
5243
5244 let r5 = [0x55u8; 32];
5245 let p5 = owner_base_rekey(&owner, &community, &me.public_key(), 5, &r5);
5246 assert_eq!(apply_server_root_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5247 let r3 = [0x33u8; 32];
5248 let p3 = owner_base_rekey(&owner, &community, &me.public_key(), 3, &r3);
5249 assert_eq!(apply_server_root_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
5250
5251 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 3).unwrap(), Some(r3));
5252 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5253 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5), "base head stayed at newest");
5254 assert_eq!(reloaded.server_root_key.as_bytes(), &r5);
5255 }
5256
5257 #[test]
5258 fn apply_server_root_rekey_authorizes_a_granted_ban_admin() {
5259 let (_tmp, _guard) = init_test_db();
5263 let owner = Keys::generate();
5264 let me = Keys::generate();
5265 become_local(&me);
5266 let community = saved_community_owned_by(&owner);
5267 let cid = community.id.to_hex();
5268
5269 let admin = Keys::generate();
5270 let role_id = "d".repeat(64);
5271 let roster = crate::community::roles::CommunityRoles {
5272 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
5273 grants: vec![crate::community::roles::MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role_id] }],
5274 };
5275 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
5276
5277 let parsed = owner_base_rekey(&admin, &community, &me.public_key(), 1, &[0x77u8; 32]);
5278 assert_eq!(
5279 apply_server_root_rekey(&community, &parsed).unwrap(),
5280 RekeyOutcome::Applied { head_advanced: true },
5281 "a BAN-granted admin (not the owner) can rotate the base"
5282 );
5283 }
5284
5285 #[test]
5286 fn apply_server_root_rekey_accepts_when_prior_root_not_held() {
5287 let (_tmp, _guard) = init_test_db();
5290 let owner = Keys::generate();
5291 let me = Keys::generate();
5292 become_local(&me);
5293 let community = saved_community_owned_by(&owner);
5294
5295 let new_root = [0x99u8; 32];
5296 let blob = super::super::rekey::build_rekey_blob(
5297 owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(5), &new_root,
5298 )
5299 .unwrap();
5300 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(4), &[0xEEu8; 32]);
5302 let outer = super::super::rekey::build_server_root_rekey_event(
5303 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
5304 crate::community::Epoch(5), crate::community::Epoch(4), &commit, &[blob],
5305 )
5306 .unwrap();
5307 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5308 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5309 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5310 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5));
5311 }
5312
5313 #[test]
5314 fn apply_server_root_rekey_rejects_channel_scope() {
5315 let (_tmp, _guard) = init_test_db();
5317 let owner = Keys::generate();
5318 let me = Keys::generate();
5319 become_local(&me);
5320 let community = saved_community_owned_by(&owner);
5321 let channel_parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
5322 assert!(apply_server_root_rekey(&community, &channel_parsed).is_err(), "channel scope rejected by base apply");
5323 }
5324
5325 #[test]
5326 fn apply_channel_rekey_not_a_recipient() {
5327 let (_tmp, _guard) = init_test_db();
5328 let owner = Keys::generate();
5329 let me = Keys::generate();
5330 become_local(&me);
5331 let community = saved_community_owned_by(&owner);
5332 let other = Keys::generate();
5334 let parsed = owner_channel_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
5335 assert_eq!(apply_channel_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
5336 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5338 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
5339 }
5340
5341 #[test]
5342 fn apply_channel_rekey_rejects_unauthorized_rotator() {
5343 let (_tmp, _guard) = init_test_db();
5344 let owner = Keys::generate();
5345 let me = Keys::generate();
5346 become_local(&me);
5347 let community = saved_community_owned_by(&owner);
5348 let rogue = Keys::generate();
5350 let parsed = owner_channel_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
5351 assert!(apply_channel_rekey(&community, &parsed).is_err(), "unauthorized rotation must be rejected");
5352 }
5353
5354 #[test]
5355 fn apply_channel_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
5356 let (_tmp, _guard) = init_test_db();
5363 let owner = Keys::generate();
5364 let me = Keys::generate();
5365 become_local(&me);
5366 let community = saved_community_owned_by(&owner);
5367 let chan = &community.channels[0];
5368 let scope = super::super::derive::RekeyScope::Channel(chan.id);
5369 let new_key = [0x33u8; 32];
5370 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &new_key).unwrap();
5371 let other_commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
5373 let outer = super::super::rekey::build_channel_rekey_event(
5374 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &chan.id,
5375 crate::community::Epoch(1), crate::community::Epoch(0), &other_commit, &[blob],
5376 )
5377 .unwrap();
5378 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5379 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
5380 assert!(matches!(outcome, RekeyOutcome::Applied { .. }),
5381 "an authorized chain must be adopted (reorg), not rejected as foreign; got {outcome:?}");
5382 assert_eq!(crate::db::community::held_epoch_key(&community.id.to_hex(), &chan.id.to_hex(), 1).unwrap(), Some(new_key));
5383 }
5384
5385 #[test]
5386 fn apply_channel_rekey_catchup_archives_without_regressing_head() {
5387 let (_tmp, _guard) = init_test_db();
5388 let owner = Keys::generate();
5389 let me = Keys::generate();
5390 become_local(&me);
5391 let community = saved_community_owned_by(&owner);
5392 let cid = community.id.to_hex();
5393 let chan_hex = community.channels[0].id.to_hex();
5394
5395 let k5 = [0x55u8; 32];
5397 let p5 = owner_channel_rekey(&owner, &community, &me.public_key(), 5, &k5);
5398 assert_eq!(apply_channel_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5399 let k3 = [0x33u8; 32];
5401 let p3 = owner_channel_rekey(&owner, &community, &me.public_key(), 3, &k3);
5402 assert_eq!(apply_channel_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
5403
5404 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(k3), "old epoch archived");
5405 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 5).unwrap(), Some(k5));
5406 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5407 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(5), "head stayed at the newest epoch");
5408 assert_eq!(reloaded.channels[0].key.as_bytes(), &k5);
5409 }
5410
5411 #[tokio::test]
5412 async fn create_community_persists_and_publishes_metadata() {
5413 use crate::community::transport::Query;
5414 use crate::stored_event::event_kind;
5415
5416 let (_tmp, _guard) = init_test_db();
5417 let relay = MemoryRelay::new();
5418 let community = create_community(&relay, "Vector HQ", "general", vec!["r1".into()])
5419 .await
5420 .expect("create");
5421
5422 assert_eq!(community.name, "Vector HQ");
5424 assert_eq!(community.channels.len(), 1);
5425 assert_eq!(community.channels[0].name, "general");
5426
5427 let loaded = crate::db::community::load_community(&community.id).unwrap().expect("persisted");
5429 assert_eq!(loaded.channels[0].name, "general");
5430 assert_eq!(loaded.server_root_key.as_bytes(), community.server_root_key.as_bytes());
5431
5432 let meta_events = relay
5435 .fetch(
5436 &Query { kinds: vec![event_kind::APPLICATION_SPECIFIC], ..Default::default() },
5437 &community.relays,
5438 )
5439 .await
5440 .unwrap();
5441 assert!(meta_events.is_empty(), "no legacy 30078 metadata events");
5442
5443 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
5446 let control = relay
5447 .fetch(
5448 &Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() },
5449 &community.relays,
5450 )
5451 .await
5452 .unwrap();
5453 assert_eq!(control.len(), 3, "GroupRoot + ChannelMetadata + Admin role editions");
5454 let owner_pk = crate::state::my_public_key().unwrap();
5455 let parsed: Vec<_> = control
5456 .iter()
5457 .filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok())
5458 .filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
5459 .collect();
5460 assert!(parsed.iter().all(|p| p.author == owner_pk), "every genesis edition authored by the owner");
5461 let root = parsed.iter().find(|p| p.entity_id == community.id.0).expect("GroupRoot edition");
5463 let root_meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&root.content).unwrap();
5464 assert_eq!(root_meta.name, "Vector HQ");
5465 assert!(root_meta.owner_attestation.is_some());
5466 let role: crate::community::roles::Role = parsed
5468 .iter()
5469 .find_map(|p| serde_json::from_str::<crate::community::roles::Role>(&p.content).ok().filter(|r| r.name == "Admin"))
5470 .expect("Admin role edition");
5471 assert_eq!(role.position, 1);
5472 assert!(role.permissions.contains(crate::community::roles::Permissions::ADMIN_ALL));
5473
5474 let cached = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap();
5476 assert_eq!(cached.roles.len(), 1);
5477 assert!(cached.grants.is_empty(), "owner is implicit position 0, takes no grant");
5478 }
5479
5480 #[tokio::test]
5481 async fn role_grant_round_trips_through_relays_and_revokes() {
5482 use crate::community::roles::Permissions;
5483 let (_tmp, _guard) = init_test_db();
5484 let relay = MemoryRelay::new();
5485 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5486 .await
5487 .expect("create");
5488 let cid = community.id.to_hex();
5489 let alice = "aa".repeat(32);
5490 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0]
5491 .role_id
5492 .clone();
5493
5494 set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()])
5496 .await
5497 .unwrap();
5498 assert!(
5499 crate::db::community::get_community_roles(&cid).unwrap().is_privileged(&alice),
5500 "local cache reflects the grant immediately"
5501 );
5502
5503 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5506 assert!(roster.has_permission(&alice, Permissions::BAN));
5507 assert!(roster.has_permission(&alice, Permissions::MANAGE_ROLES));
5508 assert_eq!(roster.roles.len(), 1);
5509 assert_eq!(roster.highest_position(&alice), Some(1));
5510
5511 set_member_grant(&relay, &community, &alice, vec![]).await.unwrap();
5513 let after = crate::db::community::get_community_roles(&cid).unwrap();
5514 assert!(!after.is_privileged(&alice), "revoked member holds no role");
5515 assert!(after.grants.is_empty(), "empty grant pruned");
5516 }
5517
5518 #[tokio::test]
5519 async fn admin_cannot_grant_a_peer_rank_role() {
5520 let (_tmp, _guard) = init_test_db();
5524 let relay = MemoryRelay::new();
5525 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5526 .await
5527 .expect("create");
5528 let cid = community.id.to_hex();
5529 let admin_role_id =
5530 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5531 let alice = Keys::generate();
5532 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5534 .await
5535 .unwrap();
5536
5537 crate::state::set_my_public_key(alice.public_key());
5539 let bob = Keys::generate().public_key();
5540 let err = grant_role(&relay, &community, bob, &admin_role_id).await.unwrap_err();
5541 assert!(err.contains("below your own"), "peer-rank grant refused, got: {err}");
5542 }
5543
5544 #[tokio::test]
5545 async fn create_community_mints_a_verifiable_owner_attestation() {
5546 let (_tmp, _guard) = init_test_db();
5549 let me = crate::state::my_public_key().unwrap();
5550 let relay = MemoryRelay::new();
5551 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5552 .await
5553 .expect("create");
5554 let att = community.owner_attestation.as_ref().expect("attestation is mandatory");
5555 let proven = super::super::owner::verify_owner_attestation(att, &community.id.to_hex());
5556 assert_eq!(proven, Some(me), "the creator is the proven owner");
5557 assert_eq!(
5559 super::super::owner::verify_owner_attestation(att, &"f".repeat(64)),
5560 None,
5561 );
5562 }
5563
5564 #[tokio::test]
5565 async fn admin_cannot_ban_a_peer_admin() {
5566 let (_tmp, _guard) = init_test_db();
5570 let relay = MemoryRelay::new();
5571 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5572 .await
5573 .expect("create");
5574 let cid = community.id.to_hex();
5575 let admin_role_id =
5576 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5577 let alice = Keys::generate();
5578 let bob = Keys::generate();
5579 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5581 .await
5582 .unwrap();
5583 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
5584 .await
5585 .unwrap();
5586
5587 become_local(&alice);
5590 let err = publish_banlist(&relay, &community, &[bob.public_key().to_hex()])
5591 .await
5592 .unwrap_err();
5593 assert!(err.contains("outranks you"), "peer-admin ban refused, got: {err}");
5594 }
5595
5596 #[tokio::test]
5597 async fn roster_reconstructs_purely_from_relay() {
5598 let (_tmp, _guard) = init_test_db();
5602 let relay = MemoryRelay::new();
5603 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5604 let cid = community.id.to_hex();
5605 let admin_role_id =
5606 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5607 let alice = "aa".repeat(32);
5608 set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()]).await.unwrap();
5609
5610 crate::db::community::set_community_roles(&cid, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
5612 assert!(crate::db::community::get_community_roles(&cid).unwrap().roles.is_empty(), "cache wiped");
5613
5614 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5615 assert!(roster.is_admin(&alice), "roster reconstructed from relay editions, not the cache");
5616 assert_eq!(roster.roles.len(), 1, "the Admin role edition folded back");
5617 }
5618
5619 #[tokio::test]
5620 async fn admin_cannot_unban_a_peer_admin() {
5621 let (_tmp, _guard) = init_test_db();
5624 let relay = MemoryRelay::new();
5625 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5626 let cid = community.id.to_hex();
5627 let admin_role_id =
5628 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5629 let alice = Keys::generate();
5630 let bob = Keys::generate();
5631 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5632 .await
5633 .unwrap();
5634 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
5635 .await
5636 .unwrap();
5637 crate::db::community::set_community_banlist(&cid, &[bob.public_key().to_hex()], 1000).unwrap();
5639
5640 become_local(&alice);
5642 let err = publish_banlist(&relay, &community, &[]).await.unwrap_err();
5643 assert!(err.contains("unban"), "unbanning a peer admin refused, got: {err}");
5644 }
5645
5646 #[tokio::test]
5647 async fn create_community_rejects_signer_identity_mismatch() {
5648 let (_tmp, _guard) = init_test_db(); let other = Keys::generate();
5653 crate::state::set_my_public_key(other.public_key()); let relay = MemoryRelay::new();
5655 let err = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap_err();
5656 assert!(err.contains("identity signer"), "signer mismatch refused, got: {err}");
5657 }
5658
5659 #[tokio::test]
5660 async fn banlist_newer_edition_applies_older_is_refused() {
5661 let (_tmp, _guard) = init_test_db();
5662 let relay = MemoryRelay::new();
5663 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5664 .await
5665 .expect("create");
5666 let id_hex = community.id.to_hex();
5667 let banlist_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::banlist_locator(&community.id));
5668 let mallory = "aa".repeat(32);
5669 let bob = "bb".repeat(32);
5670
5671 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5674 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[mallory.clone()], 1, None, 1000, None).unwrap();
5675 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5676 relay.inject(&outer, &community.relays);
5677
5678 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5680 assert_eq!(applied, vec![mallory.clone()]);
5681 let (head_v, _) = crate::db::community::get_edition_head(&id_hex, &banlist_entity).unwrap().unwrap();
5682 assert_eq!(head_v, 1, "banlist edition head advanced to v1");
5683
5684 crate::db::community::set_community_banlist(&id_hex, &[mallory.clone(), bob.clone()], 2).unwrap();
5687 crate::db::community::set_edition_head(&id_hex, &banlist_entity, 2, &[0x22u8; 32]).unwrap();
5688 let after = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5689 assert_eq!(after, vec![mallory, bob], "older relay edition refused, local banlist preserved");
5690 }
5691
5692 #[tokio::test]
5693 async fn unauthorized_banlist_edition_is_rejected() {
5694 let (_tmp, _guard) = init_test_db();
5698 let relay = MemoryRelay::new();
5699 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5700 let bob = "bb".repeat(32);
5701
5702 let mallory = Keys::generate();
5704 let inner = crate::community::roster::build_banlist_edition(&mallory, &community.id, &[bob], 1, None, 1000, None).unwrap();
5705 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5706 relay.inject(&outer, &community.relays);
5707
5708 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5710 assert!(applied.is_empty(), "an unauthorized signer's banlist edition is rejected");
5711 }
5712
5713 #[tokio::test]
5714 async fn banlist_receiver_enforces_per_target_outrank() {
5715 let (_tmp, _guard) = init_test_db();
5718 let relay = MemoryRelay::new();
5719 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5720 let cid = community.id.to_hex();
5721 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5722 let alice = Keys::generate();
5723 let bob = Keys::generate();
5724 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()]).await.unwrap();
5726 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5727
5728 let cite = authority_citation(&community, &alice.public_key().to_hex());
5731 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[bob.public_key().to_hex()], 1, None, 1000, cite.as_ref()).unwrap();
5732 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5733 relay.inject(&outer, &community.relays);
5734
5735 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5737 assert!(applied.is_empty(), "an admin can't ban a peer admin (receiver-side outrank)");
5738 }
5739
5740 #[tokio::test]
5741 async fn banlist_admin_bans_regular_member_applies() {
5742 let (_tmp, _guard) = init_test_db();
5745 let relay = MemoryRelay::new();
5746 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5747 let cid = community.id.to_hex();
5748 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5749 let alice = Keys::generate();
5750 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5751
5752 let carol = "cc".repeat(32);
5753 let cite = authority_citation(&community, &alice.public_key().to_hex());
5755 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol.clone()], 1, None, 1000, cite.as_ref()).unwrap();
5756 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5757 relay.inject(&outer, &community.relays);
5758
5759 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5760 assert_eq!(applied, vec![carol], "an admin's ban of a regular member applies");
5761 }
5762
5763 #[tokio::test]
5764 async fn owner_banlist_needs_no_citation() {
5765 let (_tmp, _guard) = init_test_db();
5768 let relay = MemoryRelay::new();
5769 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5770 let victim = "cc".repeat(32);
5771
5772 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5774 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[victim.clone()], 1, None, 1000, None).unwrap();
5775 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5776 relay.inject(&outer, &community.relays);
5777
5778 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5779 assert_eq!(applied, vec![victim], "an owner's uncited ban applies");
5780 }
5781
5782 #[tokio::test]
5783 async fn banlist_with_forged_citation_hash_is_rejected() {
5784 let (_tmp, _guard) = init_test_db();
5787 let relay = MemoryRelay::new();
5788 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5789 let cid = community.id.to_hex();
5790 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5791 let alice = Keys::generate();
5792 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5793
5794 let carol = "cc".repeat(32);
5795 let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
5797 cite.edition_hash = [0xEE; 32];
5798 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
5799 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5800 relay.inject(&outer, &community.relays);
5801
5802 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5803 assert!(applied.is_empty(), "a forged-hash citation is rejected");
5804 }
5805
5806 #[tokio::test]
5807 async fn banlist_citing_unsynced_future_version_is_rejected() {
5808 let (_tmp, _guard) = init_test_db();
5812 let relay = MemoryRelay::new();
5813 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5814 let cid = community.id.to_hex();
5815 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5816 let alice = Keys::generate();
5817 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5818
5819 let carol = "cc".repeat(32);
5820 let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
5821 cite.version += 5; let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
5823 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5824 relay.inject(&outer, &community.relays);
5825
5826 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5827 assert!(applied.is_empty(), "citing an unsynced future grant version fails closed");
5828 }
5829
5830 #[tokio::test]
5831 async fn demoted_banner_superseded_ban_is_rejected() {
5832 let (_tmp, _guard) = init_test_db();
5836 let relay = MemoryRelay::new();
5837 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5838 let cid = community.id.to_hex();
5839 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5840 let alice = Keys::generate();
5841 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5842
5843 let carol = "cc".repeat(32);
5844 let cite = authority_citation(&community, &alice.public_key().to_hex());
5846 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
5847 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5848 relay.inject(&outer, &community.relays);
5849
5850 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![]).await.unwrap();
5852
5853 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5854 assert!(applied.is_empty(), "a since-demoted banner's stale ban is rejected (refuse-superseded)");
5855 }
5856
5857 #[tokio::test]
5858 async fn withheld_revocation_cannot_resurrect_a_demoted_banners_grant() {
5859 let (_tmp, _guard) = init_test_db();
5865 let relay = MemoryRelay::new();
5866 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5867 let cid = community.id.to_hex();
5868 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5869 let alice = Keys::generate();
5870 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5871
5872 let carol = "cc".repeat(32);
5874 let cite = authority_citation(&community, &alice.public_key().to_hex());
5875 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
5876 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5877 relay.inject(&outer, &community.relays);
5878
5879 let alice_bytes = alice.public_key().to_bytes();
5882 let grant_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::grant_locator(&community.id, &alice_bytes));
5883 crate::db::community::set_edition_head(&cid, &grant_entity, 2, &[0xAB; 32]).unwrap();
5884
5885 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5886 assert!(applied.is_empty(), "a withheld revocation can't roll the banner's grant back to re-authorize them");
5887 }
5888
5889 #[tokio::test]
5890 async fn invite_registry_round_trips_and_drives_is_public() {
5891 let (_tmp, _guard) = init_test_db();
5895 let relay = MemoryRelay::new();
5896 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5897 assert!(!is_public(&community).unwrap(), "a fresh community is Private");
5898
5899 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5902 let loc = "1a".repeat(32);
5903 let inner = crate::community::roster::build_invite_links_edition(&owner, &community.id, &[loc.clone()], 1, None, 1000, None).unwrap();
5904 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5905 relay.inject(&outer, &community.relays);
5906
5907 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
5908 assert_eq!(applied, vec![loc], "the owner's link edition folds + unions from the relay");
5909 assert!(is_public(&community).unwrap(), "mode recomputed Public from the folded aggregate");
5910
5911 publish_my_invite_links(&relay, &community, &[]).await.unwrap();
5913 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
5914 assert!(applied.is_empty() && !is_public(&community).unwrap(), "an empty aggregate is Private");
5915 }
5916
5917 #[tokio::test]
5918 async fn metadata_edit_round_trips_to_a_lagging_member() {
5919 let (_tmp, _guard) = init_test_db();
5923 let relay = MemoryRelay::new();
5924 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5925 let cid = community.id.to_hex();
5926 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5927 let (genesis_v, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
5928 assert_eq!(genesis_v, 1);
5929
5930 let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
5931 edited.name = "Renamed HQ".into();
5932 edited.description = Some("now with a topic".into());
5933 let inner = crate::community::roster::build_community_root_edition(&owner, &community.id, &edited, 2, Some(&genesis_hash), 4000, None).unwrap();
5934 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5935 relay.inject(&outer, &community.relays);
5936
5937 fetch_and_apply_metadata(&relay, &community).await.unwrap();
5938 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
5939 assert_eq!(after.name, "Renamed HQ", "the owner's GroupRoot edit folded from the relay");
5940 assert_eq!(after.description.as_deref(), Some("now with a topic"));
5941 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "head advanced to v2");
5942 }
5943
5944 #[tokio::test]
5945 async fn unauthorized_metadata_edit_is_ignored() {
5946 let (_tmp, _guard) = init_test_db();
5949 let relay = MemoryRelay::new();
5950 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5951 let cid = community.id.to_hex();
5952 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
5953
5954 let mallory = Keys::generate();
5955 let mut hacked = crate::community::metadata::CommunityMetadata::of(&community);
5956 hacked.name = "Pwned".into();
5957 let inner = crate::community::roster::build_community_root_edition(&mallory, &community.id, &hacked, 2, Some(&genesis_hash), 5000, None).unwrap();
5958 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5959 relay.inject(&outer, &community.relays);
5960
5961 fetch_and_apply_metadata(&relay, &community).await.unwrap();
5962 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
5963 assert_eq!(after.name, "HQ", "a non-manage-metadata signer's GroupRoot edit is rejected");
5964 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 1, "an unauthorized edit never advances the head");
5965 }
5966
5967 #[tokio::test]
5968 async fn channel_rename_round_trips_from_owner_edition() {
5969 let (_tmp, _guard) = init_test_db();
5972 let relay = MemoryRelay::new();
5973 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5974 let cid = community.id.to_hex();
5975 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5976 let channel = community.channels[0].clone();
5977 let ch_hex = channel.id.to_hex();
5978 let (_, genesis_ch_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
5979
5980 let meta = crate::community::metadata::ChannelMetadata { name: "announcements".into() };
5981 let inner = crate::community::roster::build_channel_metadata_edition(&owner, &channel.id, &meta, 2, Some(&genesis_ch_hash), 6000, None).unwrap();
5982 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5983 relay.inject(&outer, &community.relays);
5984
5985 fetch_and_apply_metadata(&relay, &community).await.unwrap();
5986 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
5987 assert_eq!(after.channels[0].name, "announcements", "the owner's channel rename folded + applied");
5988 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced to v2");
5989 }
5990
5991 fn root_fork_v2(author: &Keys, community: &Community, name: &str, created: u64, genesis_hash: &[u8; 32]) -> (Event, [u8; 32], [u8; 32]) {
5994 let mut meta = crate::community::metadata::CommunityMetadata::of(community);
5995 meta.name = name.into();
5996 let inner = crate::community::roster::build_community_root_edition(author, &community.id, &meta, 2, Some(genesis_hash), created, None).unwrap();
5997 let self_hash = crate::community::version::edition_hash(&community.id.0, 2, Some(genesis_hash), inner.content.as_bytes());
5998 let inner_id = inner.id.to_bytes();
5999 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6000 (outer, self_hash, inner_id)
6001 }
6002
6003 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]) {
6006 let meta = crate::community::metadata::ChannelMetadata { name: name.into() };
6007 let inner = crate::community::roster::build_channel_metadata_edition(author, channel_id, &meta, 2, Some(genesis_hash), created, None).unwrap();
6008 let self_hash = crate::community::version::edition_hash(&channel_id.0, 2, Some(genesis_hash), inner.content.as_bytes());
6009 let inner_id = inner.id.to_bytes();
6010 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6011 (outer, self_hash, inner_id)
6012 }
6013
6014 #[tokio::test]
6018 async fn channel_same_version_fork_converges_to_the_lower_inner_id() {
6019 let (_tmp, _guard) = init_test_db();
6020 let relay = MemoryRelay::new();
6021 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6022 let cid = community.id.to_hex();
6023 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6024 let channel_id = community.channels[0].id;
6025 let ch_hex = channel_id.to_hex();
6026 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6027
6028 let (out_a, ha, ida) = channel_fork_v2(&owner, &community, &channel_id, "alpha", 1000, &genesis_hash);
6029 let (out_b, hb, idb) = channel_fork_v2(&owner, &community, &channel_id, "bravo", 2000, &genesis_hash);
6030 let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
6031 ("alpha", ha, ida, "bravo", hb, idb)
6032 } else {
6033 ("bravo", hb, idb, "alpha", ha, ida)
6034 };
6035 crate::db::community::set_edition_head_with_id(&cid, &ch_hex, 2, &lose_h, &lose_id).unwrap();
6037 {
6038 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6039 c.channels.iter_mut().find(|ch| ch.id == channel_id).unwrap().name = lose_name.into();
6040 crate::db::community::save_community(&c).unwrap();
6041 }
6042 relay.inject(&out_a, &community.relays);
6043 relay.inject(&out_b, &community.relays);
6044
6045 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6046 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6047 let ch_name = &after.channels.iter().find(|c| c.id == channel_id).unwrap().name;
6048 assert_eq!(ch_name, win_name, "channel converged on the lower-inner-id winner, not our held fork");
6049 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");
6050 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");
6051
6052 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6054 let after2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6055 assert_eq!(&after2.channels.iter().find(|c| c.id == channel_id).unwrap().name, win_name, "no flip back to the higher-id fork");
6056 }
6057
6058 #[tokio::test]
6061 async fn channel_same_version_fork_excludes_an_unauthorized_lower_id_edition() {
6062 let (_tmp, _guard) = init_test_db();
6063 let relay = MemoryRelay::new();
6064 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6065 let cid = community.id.to_hex();
6066 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6067 let channel_id = community.channels[0].id;
6068 let ch_hex = channel_id.to_hex();
6069 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6070
6071 let (owner_out, owner_h, owner_id) = channel_fork_v2(&owner, &community, &channel_id, "legit", 1000, &genesis_hash);
6072 let mallory = Keys::generate();
6074 let mal_out = {
6075 let mut chosen = None;
6076 for t in 1..=10_000u64 {
6077 let cand = channel_fork_v2(&mallory, &community, &channel_id, "forged", t, &genesis_hash);
6078 if cand.2 < owner_id { chosen = Some(cand.0); break; }
6079 }
6080 chosen.expect("a mallory channel edition with a lower inner id")
6081 };
6082 relay.inject(&owner_out, &community.relays);
6083 relay.inject(&mal_out, &community.relays);
6084
6085 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6086 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6087 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");
6088 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap(), (2, owner_h), "the authorized channel edition is the head");
6089 }
6090
6091 #[tokio::test]
6095 async fn epoch_primary_floor_lets_a_refounding_v1_supersede_a_held_high_version() {
6096 let (_tmp, _guard) = init_test_db();
6097 let relay = MemoryRelay::new();
6098 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6099 let cid = community.id.to_hex();
6100 for v in 2..=5u64 {
6102 crate::db::community::set_edition_head_with_id(&cid, &cid, v, &[v as u8; 32], &[v as u8; 32]).unwrap();
6103 }
6104 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5);
6105 crate::db::community::set_edition_head_with_id(&cid, &cid, 3, &[0x33; 32], &[0x33; 32]).unwrap();
6107 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5, "in-epoch downgrade refused");
6108
6109 crate::db::community::advance_server_root_epoch(&cid, 1, &[0xEE; 32]).unwrap();
6111 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0x01; 32], &[0x01; 32]).unwrap();
6112 let (v, h) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6113 assert_eq!((v, h), (1, [0x01; 32]), "epoch-1 v1 supersedes epoch-0 v5 (epoch-primary)");
6114 assert_eq!(
6115 crate::db::community::get_all_edition_heads_epoched(&cid).unwrap().get(&cid).map(|(e, v, _)| (*e, *v)),
6116 Some((1, 1)),
6117 "head now recorded at epoch 1",
6118 );
6119 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0xAA; 32], &[0x02; 32]).unwrap();
6121 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().1, [0x01; 32], "same-epoch same-version is not an advance");
6122 }
6123
6124 #[tokio::test]
6128 async fn same_version_fork_converges_to_the_lower_inner_id() {
6129 let (_tmp, _guard) = init_test_db();
6130 let relay = MemoryRelay::new();
6131 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6132 let cid = community.id.to_hex();
6133 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6134 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6135
6136 let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
6137 let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
6138 let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
6140 ("Alpha", ha, ida, "Bravo", hb, idb)
6141 } else {
6142 ("Bravo", hb, idb, "Alpha", ha, ida)
6143 };
6144 crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
6145 {
6146 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6147 c.name = lose_name.into();
6148 crate::db::community::save_community(&c).unwrap();
6149 }
6150 relay.inject(&out_a, &community.relays);
6151 relay.inject(&out_b, &community.relays);
6152
6153 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6154 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6155 assert_eq!(after.name, win_name, "converged on the lower-inner-id winner, not our own held fork");
6156 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, win_h), "head self_hash converged at the SAME version");
6157 assert_eq!(crate::db::community::get_edition_head_inner_id(&cid, &cid).unwrap(), Some(win_id), "head inner_id moved to the winner");
6158
6159 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6161 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().name, win_name, "no flip back to the higher-id fork");
6162 }
6163
6164 #[tokio::test]
6167 async fn converged_head_chains_the_next_edit_without_reforking() {
6168 let (_tmp, _guard) = init_test_db();
6169 let relay = MemoryRelay::new();
6170 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6171 let cid = community.id.to_hex();
6172 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6173 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6174
6175 let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
6176 let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
6177 let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
6178 crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
6179 relay.inject(&out_a, &community.relays);
6180 relay.inject(&out_b, &community.relays);
6181 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6182 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "converged at v2");
6183
6184 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6185 c.name = "Third".into();
6186 republish_community_metadata(&relay, &c).await.unwrap();
6187 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 3, "advanced to v3 off the converged head");
6188
6189 let empty: std::collections::HashMap<String, (u64, [u8; 32])> = std::collections::HashMap::new();
6190 let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &empty);
6191 assert_eq!(folded.root_head.as_ref().map(|h| h.version), Some(3), "a fresh fold reaches v3");
6192 assert!(!folded.gapped_entities.contains(&community.id.0), "the chain is contiguous genesis -> winner -> v3");
6193 }
6194
6195 #[tokio::test]
6198 async fn same_version_fork_excludes_an_unauthorized_lower_id_edition() {
6199 let (_tmp, _guard) = init_test_db();
6200 let relay = MemoryRelay::new();
6201 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6202 let cid = community.id.to_hex();
6203 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6204 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6205
6206 let (owner_out, owner_h, owner_id) = root_fork_v2(&owner, &community, "Legit", 1000, &genesis_hash);
6207 let mallory = Keys::generate();
6209 let (mal_out, mal_id) = {
6210 let mut chosen = None;
6211 for t in 1..=10_000u64 {
6212 let cand = root_fork_v2(&mallory, &community, "Forged", t, &genesis_hash);
6213 if cand.2 < owner_id { chosen = Some((cand.0, cand.2)); break; }
6214 }
6215 chosen.expect("a mallory edition with a lower inner id")
6216 };
6217 assert!(mal_id < owner_id, "premise: the forgery sorts first author-blind");
6218 relay.inject(&owner_out, &community.relays);
6219 relay.inject(&mal_out, &community.relays);
6220
6221 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6223 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6224 assert_eq!(after.name, "Legit", "the forgery never wins despite a lower inner id");
6225 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, owner_h), "the authorized edition is the head");
6226 }
6227
6228 #[tokio::test]
6232 async fn same_version_fork_on_an_authority_record_fails_closed() {
6233 let (_tmp, _guard) = init_test_db();
6234 let relay = MemoryRelay::new();
6235 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6236 let cid = community.id.to_hex();
6237 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6238 let bl_eid = crate::community::derive::banlist_locator(&community.id);
6239 let bl_hex = crate::simd::hex::bytes_to_hex_32(&bl_eid);
6240
6241 let prev = [0x99u8; 32]; let build_ban = |list: &[String], created: u64| {
6243 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, list, 2, Some(&prev), created, None).unwrap();
6244 let self_hash = crate::community::version::edition_hash(&bl_eid, 2, Some(&prev), inner.content.as_bytes());
6245 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6246 (outer, self_hash, inner.id.to_bytes())
6247 };
6248 let (out_a, ha, ida) = build_ban(&["aa".repeat(32)], 1000);
6249 let (out_b, hb, idb) = build_ban(&["bb".repeat(32)], 2000);
6250 let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
6253 crate::db::community::set_edition_head_with_id(&cid, &bl_hex, 2, &lose_h, &lose_id).unwrap();
6254 relay.inject(&out_a, &community.relays);
6255 relay.inject(&out_b, &community.relays);
6256
6257 let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
6258 let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &floors);
6259 assert!(folded.gapped_entities.contains(&bl_eid), "the authority-record fork is quarantined");
6260 assert!(folded.banlist_head.is_none() && folded.banlist_author.is_none(), "no banlist folded off the withheld view");
6261 }
6262
6263 #[tokio::test]
6264 async fn editions_sign_through_the_active_client_signer() {
6265 let (_tmp, _guard) = init_test_db();
6270 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6271 crate::state::set_nostr_client(nostr_sdk::Client::builder().signer(owner.clone()).build());
6272
6273 let relay = MemoryRelay::new();
6274 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6275 let cid = community.id.to_hex();
6276
6277 publish_banlist(&relay, &community, &["dd".repeat(32)]).await.unwrap();
6279 let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
6280 let folded = crate::community::roster::fold_roster(
6281 &fetch_control_inners(&relay, &community).await, &community.id, &floors);
6282 assert_eq!(folded.banlist_author, Some(owner.public_key()), "banlist signed by the client signer");
6283 assert!(folded.root_author.is_some(), "genesis GroupRoot folded");
6284 let _ = crate::state::take_nostr_client();
6285 }
6286
6287 async fn fetch_control_inners(relay: &MemoryRelay, community: &Community) -> Vec<Event> {
6289 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
6290 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
6291 let mut out = Vec::new();
6292 for ev in relay.fetch(&query, &community.relays).await.unwrap() {
6293 if let Ok(inner) = crate::community::roster::open_control_edition(&ev, &community.server_root_key) {
6294 out.push(inner);
6295 }
6296 }
6297 out
6298 }
6299
6300 fn simulate_bunker(owner: &Keys) {
6303 crate::state::set_nostr_client(nostr_sdk::Client::builder().signer(owner.clone()).build());
6304 crate::state::MY_SECRET_KEY.clear(&[]);
6305 assert!(crate::state::MY_SECRET_KEY.to_keys().is_none(), "bunker sim: no local key");
6306 }
6307
6308 #[tokio::test]
6309 async fn am_i_banned_detects_own_npub_in_banlist() {
6310 let (_tmp, _guard) = init_test_db();
6312 let relay = MemoryRelay::new();
6313 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6314 let me = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
6315 let cid = community.id.to_hex();
6316 assert!(!am_i_banned(&community), "not banned on a fresh community");
6317 crate::db::community::set_community_banlist(&cid, &[me], 1).unwrap();
6319 assert!(am_i_banned(&community), "own npub in the banlist → banned → self-remove");
6320 crate::db::community::set_community_banlist(&cid, &[], 2).unwrap();
6321 assert!(!am_i_banned(&community), "cleared banlist → not banned");
6322 }
6323
6324 #[tokio::test]
6325 async fn bunker_owner_cannot_ban_in_private_community() {
6326 let (_tmp, _guard) = init_test_db();
6329 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6330 let relay = MemoryRelay::new();
6331 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6332 simulate_bunker(&owner);
6333
6334 let victim = "cc".repeat(32);
6335 let err = publish_banlist(&relay, &community, &[victim]).await.unwrap_err();
6336 assert!(err.contains("private community") && err.contains("bunker"), "clear bunker explanation: {err}");
6337 assert!(
6338 crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap().is_empty(),
6339 "the ban must NOT half-apply (nothing published or persisted)"
6340 );
6341 let _ = crate::state::take_nostr_client();
6342 }
6343
6344 #[tokio::test]
6345 async fn bunker_owner_can_ban_in_public_community() {
6346 let (_tmp, _guard) = init_test_db();
6349 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6350 let relay = MemoryRelay::new();
6351 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6352 create_public_invite(&relay, &community, None, None).await.unwrap();
6353 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6354 assert!(is_public(&community).unwrap(), "minting a link made it Public");
6355 simulate_bunker(&owner);
6356
6357 let victim = "cc".repeat(32);
6358 publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
6359 assert_eq!(
6360 crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap(),
6361 vec![victim],
6362 "a public ban from a bunker account succeeds (no rekey needed)"
6363 );
6364 let _ = crate::state::take_nostr_client();
6365 }
6366
6367 #[tokio::test]
6368 async fn bunker_owner_cannot_privatize() {
6369 let (_tmp, _guard) = init_test_db();
6372 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6373 let relay = MemoryRelay::new();
6374 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6375 let (token, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6376 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6377 simulate_bunker(&owner);
6378
6379 let err = revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token)).await.unwrap_err();
6380 assert!(err.contains("private") && err.contains("bunker"), "clear bunker explanation: {err}");
6381 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6382 assert!(is_public(&after).unwrap(), "the revoke must NOT half-apply — community stays Public");
6383 let _ = crate::state::take_nostr_client();
6384 }
6385
6386 #[tokio::test]
6387 async fn non_owner_admin_can_edit_community_metadata() {
6388 let (_tmp, _guard) = init_test_db();
6392 let relay = MemoryRelay::new();
6393 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6394 let cid = community.id.to_hex();
6395 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6396
6397 let admin = Keys::generate();
6399 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6400 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6401
6402 let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
6404 edited.name = "Admin Renamed".into();
6405 let inner = crate::community::roster::build_community_root_edition(&admin, &community.id, &edited, 2, Some(&genesis_hash), 7000, None).unwrap();
6406 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6407 relay.inject(&outer, &community.relays);
6408
6409 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6410 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6411 assert_eq!(after.name, "Admin Renamed", "a MANAGE_METADATA admin (not the owner) can edit metadata");
6412 }
6413
6414 #[tokio::test]
6415 async fn banning_an_admin_revokes_their_role() {
6416 let (_tmp, _guard) = init_test_db();
6420 let relay = MemoryRelay::new();
6421 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6422 let cid = community.id.to_hex();
6423 create_public_invite(&relay, &community, None, None).await.unwrap();
6424 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6425
6426 let alice = Keys::generate();
6427 let alice_hex = alice.public_key().to_hex();
6428 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6429 set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
6430 let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
6431 .grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
6432 assert!(holds_role(&alice_hex), "alice is admin pre-ban");
6433
6434 publish_banlist(&relay, &community, &[alice_hex.clone()]).await.unwrap();
6435 assert!(!holds_role(&alice_hex), "banning an admin revokes their role — no dangling grant");
6436 }
6437
6438 #[tokio::test]
6439 async fn kicking_an_admin_revokes_their_role() {
6440 let (_tmp, _guard) = init_test_db();
6443 let relay = MemoryRelay::new();
6444 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6445 let cid = community.id.to_hex();
6446 let alice = Keys::generate();
6447 let alice_hex = alice.public_key().to_hex();
6448 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6449 set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
6450 let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
6451 .grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
6452 assert!(holds_role(&alice_hex), "alice is admin pre-kick");
6453
6454 publish_kick(&relay, &community, &community.channels[0], &alice_hex).await.unwrap();
6455 assert!(!holds_role(&alice_hex), "kicking an admin revokes their role");
6456 }
6457
6458 #[tokio::test]
6459 async fn republish_channel_metadata_renames_and_publishes() {
6460 let (_tmp, _guard) = init_test_db();
6463 let relay = MemoryRelay::new();
6464 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6465 let cid = community.id.to_hex();
6466 let channel = community.channels[0].clone();
6467 let ch_hex = channel.id.to_hex();
6468
6469 republish_channel_metadata(&relay, &community, &channel.id, "lobby").await.unwrap();
6470 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6471 assert_eq!(after.channels[0].name, "lobby", "the producer renamed the channel locally");
6472 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced");
6473 }
6474
6475 #[tokio::test]
6476 async fn revoking_the_last_link_privatizes_and_rotates_the_base() {
6477 let (_tmp, _guard) = init_test_db();
6482 let relay = MemoryRelay::new();
6483 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6484 assert!(!is_public(&community).unwrap(), "a fresh community is Private");
6485 assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
6486
6487 let (t1, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6489 let (t2, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6490 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6491 assert!(is_public(&c).unwrap(), "minting a link flips the mode to Public");
6492 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "minting links does NOT rotate the base");
6493
6494 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t1)).await.unwrap();
6496 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6497 assert!(is_public(&c).unwrap(), "one link remains → still Public");
6498 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "revoking a non-last link does NOT rotate");
6499
6500 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
6502 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6503 assert!(!is_public(&c).unwrap(), "revoking the last link flips to Private");
6504 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "privatize re-founded: the base key rotated");
6505
6506 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
6509 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6510 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a no-op re-revoke does not double-rotate");
6511 }
6512
6513 #[tokio::test]
6514 async fn private_ban_reseals_base_public_ban_does_not() {
6515 let (_tmp, _guard) = init_test_db();
6518 let relay = MemoryRelay::new();
6519 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6520 let victim = "cc".repeat(32);
6521
6522 assert!(!is_public(&community).unwrap(), "fresh community is Private");
6524 publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
6525 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6526 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a private-community ban re-seals the base");
6527
6528 create_public_invite(&relay, &c, None, None).await.unwrap();
6530 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6531 assert!(is_public(&c).unwrap(), "minted a link → Public");
6532 publish_banlist(&relay, &c, &[victim.clone(), "dd".repeat(32)]).await.unwrap();
6533 let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6534 assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "a public-community ban does NOT rotate the base");
6535 }
6536
6537 #[tokio::test]
6538 async fn private_ban_seals_the_banned_member_out_of_the_new_root() {
6539 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
6545 use crate::community::rekey::{open_rekey_event, rekey_pairwise_secret};
6546 use crate::types::Message;
6547 use nostr_sdk::ToBech32;
6548 let (_tmp, _guard) = init_test_db();
6549 let relay = MemoryRelay::new();
6550 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6551 let cid = community.id.to_hex();
6552 let genesis_root = *community.server_root_key.as_bytes();
6553 let channel_hex = community.channels[0].id.to_hex();
6554
6555 let victim = Keys::generate();
6557 let victim_b32 = victim.public_key().to_bech32().unwrap();
6558 let mut m = Message::default();
6559 m.id = "aa".repeat(32);
6560 m.npub = Some(victim_b32.clone());
6561 m.at = 1000;
6562 crate::db::events::save_message(&channel_hex, &m).await.unwrap();
6563 assert!(
6564 crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
6565 "victim is observed before the ban"
6566 );
6567
6568 publish_banlist(&relay, &community, &[victim.public_key().to_hex()]).await.unwrap();
6570 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
6571 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "private ban re-seals the base");
6572 assert!(
6573 !crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
6574 "the banned victim is no longer observed (banlist hex → bech32 reconciliation worked)"
6575 );
6576
6577 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
6579 let found = relay
6580 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
6581 .await
6582 .unwrap();
6583 assert_eq!(found.len(), 1, "the base rekey is published");
6584 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
6585 let secret = rekey_pairwise_secret(victim.secret_key(), &parsed.rotator).unwrap();
6586 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
6587 assert!(
6588 parsed.blobs.iter().all(|b| b.locator != loc),
6589 "the BANNED victim has NO blob — sealed OUT of the new root (read access is actually cut)"
6590 );
6591 }
6592
6593 struct SwapDuringPublishRelay {
6597 inner: MemoryRelay,
6598 }
6599 #[async_trait::async_trait]
6600 impl Transport for SwapDuringPublishRelay {
6601 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
6602 crate::state::bump_session_generation();
6603 self.inner.publish(event, relays).await
6604 }
6605 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
6606 crate::state::bump_session_generation();
6607 self.inner.publish_durable(event, relays).await
6608 }
6609 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
6610 self.inner.fetch(query, relays).await
6611 }
6612 }
6613
6614 #[tokio::test]
6618 async fn account_swap_during_grant_publish_skips_the_local_persist() {
6619 let (_tmp, _guard) = init_test_db();
6620 let setup = MemoryRelay::new();
6621 let community = create_community(&setup, "HQ", "general", vec!["r1".into()]).await.unwrap();
6622 let cid = community.id.to_hex();
6623 let member = "cc".repeat(32);
6624 let entity_hex = crate::simd::hex::bytes_to_hex_32(
6625 &crate::community::derive::grant_locator(&community.id, &crate::simd::hex::hex_to_bytes_32(&member)));
6626 assert!(crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_none(), "no grant head yet");
6627
6628 let swap = SwapDuringPublishRelay { inner: MemoryRelay::new() };
6629 set_member_grant(&swap, &community, &member, vec!["a".repeat(64)]).await.unwrap();
6630
6631 assert!(
6632 crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_none(),
6633 "session straddled a swap → persist skipped → no local grant head (account B uncorrupted)"
6634 );
6635 }
6636
6637 #[tokio::test]
6641 async fn account_swap_during_ban_publish_applies_nothing_locally() {
6642 let (_tmp, _guard) = init_test_db();
6643 let setup = MemoryRelay::new();
6644 let community = create_community(&setup, "HQ", "general", vec!["r1".into()]).await.unwrap();
6645 let cid = community.id.to_hex();
6646 assert!(!is_public(&community).unwrap(), "fresh community is Private (a ban would normally re-seal)");
6647
6648 let swap = SwapDuringPublishRelay { inner: MemoryRelay::new() };
6649 publish_banlist(&swap, &community, &["cc".repeat(32)]).await.unwrap();
6650
6651 assert!(crate::db::community::get_community_banlist(&cid).unwrap().is_empty(),
6652 "banlist persist skipped on the stale session");
6653 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
6654 crate::community::Epoch(0), "no read-cut re-seal → base NOT rotated into the wrong account");
6655 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(),
6656 "read_cut_pending untouched (need_cut requires is_valid())");
6657 }
6658
6659 #[tokio::test]
6662 async fn swap_session_clears_per_account_state_and_keys() {
6663 let (_tmp, _guard) = init_test_db();
6664 {
6665 let mut st = crate::state::STATE.lock().await;
6666 st.db_loaded = true;
6667 st.is_syncing = true;
6668 }
6669 assert!(crate::state::MY_SECRET_KEY.has_key(), "account A holds a live key");
6670
6671 crate::VectorCore.swap_session().await;
6672
6673 let st = crate::state::STATE.lock().await;
6674 assert!(st.chats.is_empty() && st.profiles.is_empty(), "STATE chats/profiles cleared on swap");
6675 assert!(!st.db_loaded && !st.is_syncing, "db_loaded / is_syncing reset");
6676 assert!(!crate::state::MY_SECRET_KEY.has_key(), "key vault cleared — no leak into account B");
6677 }
6678
6679 #[tokio::test]
6683 async fn join_finalization_persists_and_registers_the_channel() {
6684 let (_tmp, _guard) = init_test_db();
6685 crate::state::STATE.lock().await.chats.clear(); let relay = MemoryRelay::new();
6687 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6688 become_local(&Keys::generate());
6690
6691 crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await.unwrap();
6692
6693 assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community persisted on join");
6694 assert!(!crate::state::STATE.lock().await.chats.is_empty(), "the channel is registered as a chat");
6695 }
6696
6697 #[tokio::test]
6701 async fn join_finalization_tears_down_a_banned_joiner() {
6702 let (_tmp, _guard) = init_test_db();
6703 let relay = MemoryRelay::new();
6704 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6705 create_public_invite(&relay, &community, None, None).await.unwrap();
6707 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6708
6709 let joiner = Keys::generate();
6711 publish_banlist(&relay, &community, &[joiner.public_key().to_hex()]).await.unwrap();
6712 become_local(&joiner);
6713 assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community present pre-join");
6714
6715 let result = crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await;
6716 assert!(result.is_err(), "a banned joiner's finalize must fail");
6717 assert!(result.unwrap_err().to_string().contains("banned"), "the error names the ban");
6718 assert!(
6719 crate::db::community::load_community(&community.id).unwrap().is_none(),
6720 "the just-saved community is torn back down — no orphaned row for a banned joiner"
6721 );
6722 }
6723
6724 #[tokio::test]
6730 async fn delete_community_wipes_every_community_scoped_table() {
6731 let (_tmp, _guard) = init_test_db();
6732 let relay = MemoryRelay::new();
6733 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6734 let cid = community.id.to_hex();
6735
6736 crate::db::community::store_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[0x11u8; 32]).unwrap();
6738 crate::db::community::save_public_invite("tok", &cid, "https://x/invite#y", None, None).unwrap();
6739 crate::db::community::save_pending_invite(&cid, "{}", "npub1inviter").unwrap();
6740 crate::db::community::set_edition_head(&cid, &cid, 1, &[0x22u8; 32]).unwrap();
6741 crate::db::community::set_community_banlist(&cid, &["cc".repeat(32)], 100).unwrap();
6742
6743 assert!(crate::db::community::community_exists(&community.id).unwrap());
6745 assert!(!crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
6746 assert!(!crate::db::community::list_public_invites(&cid).unwrap().is_empty());
6747 assert!(crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid));
6748 assert!(!crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty());
6749 assert!(!crate::db::community::get_community_banlist(&cid).unwrap().is_empty());
6750
6751 crate::db::community::delete_community(&cid).unwrap();
6752
6753 assert!(!crate::db::community::community_exists(&community.id).unwrap(), "communities row gone");
6755 assert!(crate::db::community::load_community(&community.id).unwrap().is_none(), "community not loadable");
6756 assert!(crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys wiped");
6757 assert!(crate::db::community::list_public_invites(&cid).unwrap().is_empty(), "public invites wiped");
6758 assert!(!crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid), "pending invites wiped");
6759 assert!(crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty(), "edition heads wiped");
6760 assert!(crate::db::community::get_community_banlist(&cid).unwrap().is_empty(), "banlist wiped with the channels");
6761 }
6762
6763 #[tokio::test]
6767 async fn fetch_control_folded_skips_junk_injected_at_the_coordinate() {
6768 let (_tmp, _guard) = init_test_db();
6769 let relay = MemoryRelay::new();
6770 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6771 let owner_hex = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
6772
6773 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
6775 let junk = nostr_sdk::EventBuilder::new(nostr_sdk::Kind::Custom(event_kind::COMMUNITY_CONTROL), "not a sealed edition")
6776 .tags([nostr_sdk::Tag::custom(nostr_sdk::TagKind::Custom("z".into()), [z])])
6777 .sign_with_keys(&Keys::generate())
6778 .unwrap();
6779 relay.publish(&junk, &community.relays).await.unwrap();
6780
6781 let folded = fetch_control_folded(&relay, &community).await.unwrap();
6782 assert!(
6783 !crate::community::roster::authorize_delegation(&folded, Some(&owner_hex)).roles.is_empty(),
6784 "the genuine Admin role still folds; the un-openable junk is silently dropped"
6785 );
6786 }
6787
6788 #[tokio::test]
6791 async fn fetch_control_folded_on_dead_relays_is_empty_not_a_panic() {
6792 let (_tmp, _guard) = init_test_db();
6793 let community = saved_community_owned_by(&Keys::generate());
6794 let folded = fetch_control_folded(&FailingRelay, &community).await.unwrap();
6795 assert!(folded.roles.roles.is_empty() && folded.root_meta.is_none(), "dead relays → empty fold, no panic");
6796 }
6797
6798 #[tokio::test]
6799 async fn successful_private_ban_leaves_no_read_cut_pending() {
6800 let (_tmp, _guard) = init_test_db();
6802 let relay = MemoryRelay::new();
6803 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6804 let cid = community.id.to_hex();
6805 publish_banlist(&relay, &community, &["cc".repeat(32)]).await.unwrap();
6806 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "a successful re-seal leaves no pending read-cut");
6807 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6808 assert_eq!(c.server_root_epoch, crate::community::Epoch(1));
6809 }
6810
6811 #[tokio::test]
6812 async fn failed_reseal_sets_pending_then_sync_retry_recovers() {
6813 let (_tmp, _guard) = init_test_db();
6818 let relay = RekeyFailingRelay::new(); let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6820 let cid = community.id.to_hex();
6821 let victim = "cc".repeat(32);
6822
6823 assert!(publish_banlist(&relay, &community, &[victim.clone()]).await.is_err(), "the re-seal's base rekey fails");
6825 assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "a failed re-seal leaves read_cut_pending set");
6826 assert_eq!(crate::db::community::get_community_banlist(&cid).unwrap(), vec![victim.clone()], "the ban itself still applied");
6827 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6828 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "base NOT rotated while the re-seal is pending");
6829
6830 relay.allow_rekey();
6832 retry_pending_read_cut(&relay, &c).await.unwrap();
6833 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the retry succeeds");
6834 let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6835 assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "the read-cut finally rotated the base");
6836 }
6837
6838 #[tokio::test]
6839 async fn privatize_reseals_to_observed_participants_not_just_owner() {
6840 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
6844 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
6845 use crate::types::Message;
6846 use nostr_sdk::ToBech32;
6847 let (_tmp, _guard) = init_test_db();
6848 let relay = MemoryRelay::new();
6849 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6850 let cid = community.id.to_hex();
6851 let genesis_root = *community.server_root_key.as_bytes();
6852 let channel_hex = community.channels[0].id.to_hex();
6853
6854 let alice = Keys::generate();
6856 let alice_b32 = alice.public_key().to_bech32().unwrap();
6857 let mut m = Message::default();
6858 m.id = "aa".repeat(32);
6859 m.npub = Some(alice_b32.clone());
6860 m.at = 1000;
6861 crate::db::events::save_message(&channel_hex, &m).await.unwrap();
6862 assert!(
6863 crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &alice_b32),
6864 "alice is an observed participant"
6865 );
6866
6867 let (token_hex, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6869 revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token_hex)).await.unwrap();
6870 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
6871 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "privatize rotated the base");
6872
6873 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
6876 let found = relay
6877 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
6878 .await
6879 .unwrap();
6880 assert_eq!(found.len(), 1, "the base rekey is published");
6881 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
6882 let secret = rekey_pairwise_secret(alice.secret_key(), &parsed.rotator).unwrap();
6883 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
6884 let alice_blob = parsed.blobs.iter().find(|b| b.locator == loc).expect("alice's blob present (NOT sealed out)");
6885 let recovered = open_rekey_blob(alice.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, alice_blob).unwrap();
6886 assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "alice recovers the new root = owner's advanced base");
6887 }
6888
6889 #[tokio::test]
6890 async fn unpermissioned_invite_links_edition_is_rejected() {
6891 let (_tmp, _guard) = init_test_db();
6895 let relay = MemoryRelay::new();
6896 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6897
6898 let mallory = Keys::generate();
6899 let loc = "2b".repeat(32);
6900 let inner = crate::community::roster::build_invite_links_edition(&mallory, &community.id, &[loc], 1, None, 1000, None).unwrap();
6901 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6902 relay.inject(&outer, &community.relays);
6903
6904 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
6905 assert!(applied.is_empty(), "an unpermissioned member's link edition is rejected");
6906 assert!(!is_public(&community).unwrap(), "mode stays Private despite the forged edition");
6907 }
6908
6909 #[tokio::test]
6910 async fn invite_links_union_across_authorized_creators() {
6911 let (_tmp, _guard) = init_test_db();
6915 let relay = MemoryRelay::new();
6916 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6917 let cid = community.id.to_hex();
6918
6919 create_public_invite(&relay, &community, None, None).await.unwrap();
6921 let owner_loc = public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(
6922 &crate::db::community::list_public_invites(&cid).unwrap()[0].token));
6923
6924 let admin = Keys::generate();
6926 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6927 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6928 let admin_loc = "ab".repeat(32);
6929 let inner = crate::community::roster::build_invite_links_edition(&admin, &community.id, &[admin_loc.clone()], 1, None, 2000, None).unwrap();
6930 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6931 relay.inject(&outer, &community.relays);
6932
6933 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
6934 assert!(agg.contains(&owner_loc), "owner's link in the aggregate");
6935 assert!(agg.contains(&admin_loc), "the granted admin's link unions in too");
6936 assert!(is_public(&community).unwrap());
6937
6938 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6942 let owner_token = crate::db::community::list_public_invites(&cid).unwrap()[0].token.clone();
6943 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&owner_token)).await.unwrap();
6944 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6945 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "another creator's link remains → no privatize rekey");
6946 assert!(is_public(&c).unwrap(), "still Public (admin's link is live)");
6947 }
6948
6949 #[tokio::test]
6950 async fn failed_banlist_publish_does_not_persist_locally() {
6951 let (_tmp, _guard) = init_test_db();
6954 let relay = MemoryRelay::new();
6955 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6956 let id_hex = community.id.to_hex();
6957 assert!(crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty());
6958
6959 let victim = "cc".repeat(32);
6960 let err = publish_banlist(&FailingRelay, &community, &[victim]).await;
6961 assert!(err.is_err(), "a failed publish must propagate");
6962 assert!(
6963 crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty(),
6964 "local banlist must be untouched when the publish failed"
6965 );
6966 }
6967
6968 #[tokio::test]
6969 async fn metadata_failed_publish_does_not_persist_locally() {
6970 let (_tmp, _guard) = init_test_db();
6974 let relay = MemoryRelay::new();
6975 let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6976 community.name = "Renamed HQ".to_string();
6977 assert!(republish_community_metadata(&FailingRelay, &community).await.is_err());
6978 let loaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
6979 assert_eq!(loaded.name, "HQ", "a failed metadata publish leaves the local name unchanged");
6980 }
6981
6982 #[tokio::test]
6983 async fn send_persists_key_then_delete_round_trip() {
6984 let (_tmp, _guard) = init_test_db();
6985 let relay = MemoryRelay::new();
6986 let community = Community::create("HQ", "general", vec!["r1".into()]);
6987 let channel = community.channels[0].clone();
6988 let alice = Keys::generate();
6989
6990 let _outer = send_message(&relay, &community, &channel, &alice, "deletable", 1).await.unwrap();
6992 let before = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
6993 assert_eq!(before.len(), 1);
6994 let message_id = before[0].message_id.to_hex();
6995
6996 delete_message(&relay, &message_id).await.unwrap();
6998 let after = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
6999 assert!(after.is_empty(), "message should be deleted after delete_message");
7000
7001 assert!(delete_message(&relay, &message_id).await.is_err());
7003 }
7004
7005 #[tokio::test]
7006 async fn failed_delete_publish_preserves_key() {
7007 let (_tmp, _guard) = init_test_db();
7010 let relay = MemoryRelay::new();
7011 let community = Community::create("HQ", "general", vec!["r1".into()]);
7012 let channel = community.channels[0].clone();
7013 let alice = Keys::generate();
7014 send_message(&relay, &community, &channel, &alice, "delete me", 1).await.unwrap();
7015 let message_id = fetch_channel_messages(&relay, &community, &channel).await.unwrap()[0]
7016 .message_id
7017 .to_hex();
7018
7019 assert!(delete_message(&FailingRelay, &message_id).await.is_err());
7021
7022 delete_message(&relay, &message_id).await.unwrap();
7024 assert!(fetch_channel_messages(&relay, &community, &channel).await.unwrap().is_empty());
7025 }
7026
7027 #[tokio::test]
7028 async fn delete_unknown_message_errors() {
7029 let (_tmp, _guard) = init_test_db();
7030 let relay = MemoryRelay::new();
7031 let fake = Keys::generate();
7033 let bogus = EventBuilder::new(Kind::Custom(1), "x").sign_with_keys(&fake).unwrap().id;
7034 assert!(delete_message(&relay, &bogus.to_hex()).await.is_err());
7035 }
7036
7037 #[tokio::test]
7038 async fn accept_invite_persists_member_view() {
7039 let (_tmp, _guard) = init_test_db();
7040 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7041 let invite = crate::community::invite::build_invite(&owner);
7042
7043 let joined = accept_invite(&invite).expect("accept");
7044 assert!(!is_proven_owner(&joined), "joined as member, not owner");
7045 let loaded = crate::db::community::load_community(&owner.id).unwrap().expect("saved");
7047 assert_eq!(loaded.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
7048 }
7049
7050 #[tokio::test]
7051 async fn accept_invite_does_not_downgrade_owned_community() {
7052 let (_tmp, _guard) = init_test_db();
7055 let relay = MemoryRelay::new();
7056 let owner = create_community(&relay, "HQ", "general", vec![]).await.unwrap();
7057 assert!(is_proven_owner(&owner), "we are the proven owner");
7058
7059 let invite = crate::community::invite::build_invite(&owner);
7060 let err = accept_invite(&invite).unwrap_err();
7061 assert!(err.contains("already own"), "must refuse to downgrade an owned community, got: {err}");
7062
7063 let reloaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
7065 assert_eq!(reloaded.server_root_key.as_bytes(), owner.server_root_key.as_bytes());
7066 }
7067
7068 #[tokio::test]
7069 async fn accept_invite_rejects_id_collision_under_different_authority() {
7070 let (_tmp, _guard) = init_test_db();
7075 let legit = Community::create("X", "general", vec!["wss://legit".into()]);
7076 let member_x = accept_invite(&crate::community::invite::build_invite(&legit)).unwrap();
7077 let original_key = member_x.channels[0].key.as_bytes().to_vec();
7078
7079 let attacker = Community::create("evil", "general", vec!["wss://evil".into()]);
7081 let mut hostile = crate::community::invite::build_invite(&attacker);
7082 hostile.community_id = legit.id.to_hex();
7083 assert_ne!(hostile.server_root_key, crate::simd::hex::bytes_to_hex_32(member_x.server_root_key.as_bytes()));
7086
7087 assert!(accept_invite(&hostile).is_err(), "id-collision under new authority must be rejected");
7088
7089 let reloaded = crate::db::community::load_community(&legit.id).unwrap().unwrap();
7091 assert_eq!(reloaded.channels[0].key.as_bytes().to_vec(), original_key);
7092 assert_eq!(reloaded.relays, vec!["wss://legit".to_string()]);
7093 }
7094
7095 #[tokio::test]
7096 async fn rejected_accept_leaves_pending_invite_intact() {
7097 let (_tmp, _guard) = init_test_db();
7100
7101 let owner = attested_community("HQ", "general", vec![]);
7103 crate::db::community::save_community(&owner).unwrap();
7104 let bundle = crate::community::invite::build_invite(&owner).to_json().unwrap();
7105 let cid = owner.id.to_hex();
7106 crate::db::community::save_pending_invite(&cid, &bundle, "npub1inviter").unwrap();
7107
7108 let peeked = crate::db::community::get_pending_invite(&cid).unwrap().expect("parked");
7110 let invite = crate::community::invite::CommunityInvite::from_json(&peeked).unwrap();
7111 assert!(accept_invite(&invite).is_err(), "owning the id → reject");
7112 assert!(
7113 crate::db::community::pending_invite_exists(&cid).unwrap(),
7114 "rejected accept must leave the invite parked"
7115 );
7116
7117 let other = Community::create("Other", "general", vec![]);
7119 let ob = crate::community::invite::build_invite(&other).to_json().unwrap();
7120 let ocid = other.id.to_hex();
7121 crate::db::community::save_pending_invite(&ocid, &ob, "npub1inviter").unwrap();
7122 let op = crate::db::community::get_pending_invite(&ocid).unwrap().unwrap();
7123 let oinvite = crate::community::invite::CommunityInvite::from_json(&op).unwrap();
7124 accept_invite(&oinvite).expect("accept ok");
7125 crate::db::community::delete_pending_invite(&ocid).unwrap();
7126 assert!(!crate::db::community::pending_invite_exists(&ocid).unwrap(), "cleared on success");
7127 }
7128
7129 #[tokio::test]
7130 async fn public_invite_create_fetch_accept_revoke_round_trip() {
7131 let (_tmp, _guard) = init_test_db();
7132 let relay = MemoryRelay::new();
7133 let mut owner = Community::create("Public HQ", "general", vec!["r1".into(), "r2".into()]);
7134 owner.description = Some("everyone welcome".into());
7135 let owner_keys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7139 owner.owner_attestation = Some(
7140 crate::community::owner::build_owner_attestation_unsigned(owner_keys.public_key(), &owner.id.to_hex())
7141 .sign_with_keys(&owner_keys).unwrap().as_json(),
7142 );
7143 let (token_hex, url) = create_public_invite(&relay, &owner, None, None).await.expect("mint");
7145 assert!(url.contains('#'));
7146 assert_eq!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().len(), 1);
7147
7148 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7150 assert_eq!(crate::simd::hex::bytes_to_hex_32(&token), token_hex);
7151 let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("fetch");
7152 assert_eq!(bundle.preview.name, "Public HQ");
7153 assert_eq!(bundle.preview.description.as_deref(), Some("everyone welcome"));
7154
7155 let joined = accept_public_invite(&bundle, 0).expect("accept");
7156 assert_eq!(joined.id, owner.id);
7157 assert_eq!(joined.description.as_deref(), Some("everyone welcome"), "preview patched in");
7158
7159 revoke_public_invite(&relay, &owner, &token).await.expect("revoke");
7161 assert!(fetch_public_invite(&relay, &relays, &token).await.is_err(), "revoked link is dead");
7162 assert!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().is_empty());
7163 }
7164
7165 #[tokio::test]
7166 async fn revoked_invite_dies_even_if_one_relay_kept_the_bundle() {
7167 let (_tmp, _guard) = init_test_db();
7171 let relay = MemoryRelay::new();
7172 let owner = attested_community("HQ", "general", vec!["r1".into(), "r2".into()]);
7173 let (_token_hex, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
7174 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7175 assert!(fetch_public_invite(&relay, &relays, &token).await.is_ok(), "live on both relays");
7176
7177 let tombstone = public_invite::build_public_invite_tombstone(&token).unwrap();
7179 relay.inject(&tombstone, &["r1".to_string()]);
7180
7181 assert!(
7182 fetch_public_invite(&relay, &relays, &token).await.is_err(),
7183 "a tombstone on any one relay kills the link, even with a stale live bundle elsewhere",
7184 );
7185 }
7186
7187 #[tokio::test]
7188 async fn fetch_skips_relay_shadow_junk_to_genuine_bundle() {
7189 use nostr_sdk::prelude::{EventBuilder, Keys, Kind, Tag, TagKind, Timestamp};
7193
7194 let (_tmp, _guard) = init_test_db();
7195 let relay = MemoryRelay::new();
7196 let owner = attested_community("HQ", "general", vec!["r1".into()]);
7197 let (_t, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
7198 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7199
7200 let attacker = Keys::generate();
7203 let junk = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "garbage")
7204 .tags([
7205 Tag::identifier(public_invite::locator_hex(&token)),
7206 Tag::custom(TagKind::Custom("vsk".into()), ["6".to_string()]),
7207 Tag::custom(TagKind::Custom("v".into()), ["1".to_string()]),
7208 ])
7209 .custom_created_at(Timestamp::from_secs(9_000_000_000))
7210 .sign_with_keys(&attacker)
7211 .unwrap();
7212 relay.publish(&junk, &relays).await.unwrap();
7213
7214 let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("genuine survives shadow");
7216 assert_eq!(bundle.preview.name, "HQ");
7217 }
7218
7219 #[tokio::test]
7220 async fn expired_public_invite_is_refused() {
7221 let (_tmp, _guard) = init_test_db();
7222 let relay = MemoryRelay::new();
7223 let owner = attested_community("HQ", "general", vec!["r1".into()]);
7224 let (_t, url) = create_public_invite(&relay, &owner, Some(1000), None).await.unwrap();
7225 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7226 let bundle = fetch_public_invite(&relay, &relays, &token).await.unwrap();
7227 assert!(accept_public_invite(&bundle, 2000).is_err());
7229 assert!(crate::db::community::load_community(&owner.id).unwrap().is_none());
7230 }
7231
7232 #[tokio::test]
7233 async fn republish_metadata_saves_and_publishes() {
7234 use crate::community::CommunityImage;
7235 let (_tmp, _guard) = init_test_db();
7236 let relay = MemoryRelay::new();
7237 let mut owner = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7240 let cid = owner.id.to_hex();
7241
7242 owner.name = "HQ Renamed".into();
7244 owner.description = Some("now with topic".into());
7245 owner.icon = Some(CommunityImage {
7246 url: "https://b/x".into(), key: "aa".repeat(32), nonce: "bb".repeat(12),
7247 hash: "cc".repeat(32), ext: "png".into(),
7248 });
7249 republish_community_metadata(&relay, &owner).await.expect("republish");
7250
7251 let loaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
7253 assert_eq!(loaded.name, "HQ Renamed");
7254 assert_eq!(loaded.description.as_deref(), Some("now with topic"));
7255 assert_eq!(loaded.icon.unwrap().url, "https://b/x");
7256
7257 let (head_v, _) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
7260 assert_eq!(head_v, 2, "GroupRoot edition advanced v1 (create) → v2 (republish)");
7261 let z = crate::community::roster::control_pseudonym(&owner.server_root_key, &owner.id, crate::community::Epoch(0));
7262 let control = relay
7263 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &owner.relays)
7264 .await
7265 .unwrap();
7266 let newest = control
7267 .iter()
7268 .filter_map(|o| crate::community::roster::open_control_edition(o, &owner.server_root_key).ok())
7269 .filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
7270 .filter(|p| p.entity_id == owner.id.0)
7271 .max_by_key(|p| p.version)
7272 .expect("GroupRoot edition on the relay");
7273 let meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&newest.content).unwrap();
7274 assert_eq!(meta.name, "HQ Renamed");
7275 assert_eq!(meta.icon.unwrap().ext, "png");
7276 }
7277
7278 #[tokio::test]
7279 async fn member_cannot_republish_metadata() {
7280 let (_tmp, _guard) = init_test_db();
7281 let relay = MemoryRelay::new();
7282 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7283 let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
7284 assert!(republish_community_metadata(&relay, &member).await.is_err());
7285 }
7286
7287 #[tokio::test]
7288 async fn member_cannot_mint_public_invite() {
7289 let (_tmp, _guard) = init_test_db();
7290 let relay = MemoryRelay::new();
7291 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7292 let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
7293 assert!(create_public_invite(&relay, &member, None, None).await.is_err(), "members can't mint links");
7294 }
7295
7296 #[tokio::test]
7297 async fn accept_oversized_bundle_rejected() {
7298 let (_tmp, _guard) = init_test_db();
7299 let owner = Community::create("HQ", "general", vec![]);
7300 let mut invite = crate::community::invite::build_invite(&owner);
7301 let template = invite.channels[0].clone();
7303 for _ in 0..300 {
7304 invite.channels.push(template.clone());
7305 }
7306 assert!(accept_invite(&invite).is_err(), "oversized bundle must be rejected");
7307 assert!(crate::db::community::load_community(&owner.id).unwrap().is_none(), "nothing persisted");
7308 }
7309
7310 async fn publish_tombstone<T: Transport + ?Sized>(transport: &T, community: &Community, author: &Keys, created_at: u64) {
7316 let inner = crate::community::roster::build_group_dissolved_edition_unsigned(author.public_key(), &community.id, created_at)
7317 .sign_with_keys(author).unwrap();
7318 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch).unwrap();
7319 transport.publish_durable(&outer, &community.relays).await.unwrap();
7320 }
7321
7322 struct RekeyCountingRelay {
7325 inner: MemoryRelay,
7326 rekeys: std::sync::atomic::AtomicUsize,
7327 }
7328 impl RekeyCountingRelay {
7329 fn new() -> Self { Self { inner: MemoryRelay::new(), rekeys: std::sync::atomic::AtomicUsize::new(0) } }
7330 fn count(&self, e: &Event) {
7331 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
7332 self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7333 }
7334 }
7335 }
7336 #[async_trait::async_trait]
7337 impl Transport for RekeyCountingRelay {
7338 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish(e, r).await }
7339 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish_durable(e, r).await }
7340 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
7341 }
7342
7343 #[tokio::test]
7344 async fn owner_tombstone_folds_to_dissolved() {
7345 let (_tmp, _guard) = init_test_db();
7346 let relay = MemoryRelay::new();
7347 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7349 let cid = community.id.to_hex();
7350 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7351 publish_tombstone(&relay, &community, &owner, 1000).await;
7352
7353 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "alive before the fold");
7354 fetch_and_apply_control(&relay, &community).await.unwrap();
7355 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "owner tombstone seals the community");
7356 }
7357
7358 #[tokio::test]
7359 async fn non_owner_tombstone_is_ignored() {
7360 let (_tmp, _guard) = init_test_db();
7361 let relay = MemoryRelay::new();
7362 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7363 let cid = community.id.to_hex();
7364 let mallory = Keys::generate();
7367 publish_tombstone(&relay, &community, &mallory, 1000).await;
7368
7369 fetch_and_apply_control(&relay, &community).await.unwrap();
7370 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "a non-owner tombstone is ignored");
7371 }
7372
7373 #[tokio::test]
7374 async fn unreadable_deed_rejects_the_tombstone() {
7375 let (_tmp, _guard) = init_test_db();
7376 let relay = MemoryRelay::new();
7377 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7378 let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7379 let cid = community.id.to_hex();
7380 publish_tombstone(&relay, &community, &owner, 1000).await;
7381 community.owner_attestation = None;
7383 crate::db::community::save_community(&community).unwrap();
7384 let stripped = crate::db::community::load_community(&community.id).unwrap().unwrap();
7385
7386 fetch_and_apply_control(&relay, &stripped).await.unwrap();
7387 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "unverifiable tombstone is rejected, not death-by-default");
7388 }
7389
7390 #[tokio::test]
7391 async fn binary_seal_drops_every_subsequent_event_with_no_timestamp_test() {
7392 let (_tmp, _guard) = init_test_db();
7393 let relay = MemoryRelay::new();
7394 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7395 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7396 let cid = community.id.to_hex();
7397 publish_tombstone(&relay, &community, &owner, 1000).await;
7398 fetch_and_apply_control(&relay, &community).await.unwrap();
7399 assert!(crate::db::community::get_community_dissolved(&cid).unwrap());
7400
7401 let sealed = crate::db::community::load_community(&community.id).unwrap().unwrap();
7403 let channel = sealed.channels[0].clone();
7404 let me = owner.public_key();
7405
7406 let backdated = super::super::envelope::seal_message(
7408 &Keys::generate(), &channel.key, &channel.id, channel.epoch, "ghost", 1,
7409 ).unwrap();
7410 let mut state = crate::state::ChatState::new();
7411 assert!(super::super::inbound::process_incoming(&mut state, &backdated, &channel, &me).is_none(),
7412 "a backdated message after the seal is dropped (binary seal, no timestamp test)");
7413
7414 publish_tombstone(&relay, &sealed, &owner, 2000).await;
7416 assert_eq!(fetch_and_apply_control(&relay, &sealed).await.unwrap(), 0,
7417 "control fold stops advancing once sealed");
7418 }
7419
7420 #[tokio::test]
7421 async fn dissolve_community_emits_no_rekey_and_no_epoch_bump() {
7422 let (_tmp, _guard) = init_test_db();
7423 let relay = RekeyCountingRelay::new();
7424 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7425 let cid = community.id.to_hex();
7426 create_public_invite(&relay, &community, None, None).await.unwrap();
7428 let before_epoch = crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch;
7429
7430 dissolve_community(&relay, &community).await.unwrap();
7431
7432 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "sealed locally");
7433 assert_eq!(relay.rekeys.load(std::sync::atomic::Ordering::Relaxed), 0,
7434 "dissolution publishes NO 3303 rekey (no last-link privatize re-founding)");
7435 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch, before_epoch,
7436 "base epoch unchanged — dissolution rotates nothing");
7437 }
7438
7439 #[tokio::test]
7440 async fn duplicate_owner_tombstones_are_idempotent() {
7441 let (_tmp, _guard) = init_test_db();
7442 let relay = MemoryRelay::new();
7443 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7444 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7445 let cid = community.id.to_hex();
7446 publish_tombstone(&relay, &community, &owner, 1000).await;
7448 publish_tombstone(&relay, &community, &owner, 2000).await;
7449
7450 fetch_and_apply_control(&relay, &community).await.unwrap();
7451 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "duplicates still just dissolve, no error");
7452 assert_eq!(fetch_and_apply_control(&relay, &community).await.unwrap(), 0);
7454 }
7455
7456 #[test]
7457 fn apply_server_root_rekey_refuses_once_dissolved() {
7458 let (_tmp, _guard) = init_test_db();
7459 let owner = Keys::generate();
7460 let me = Keys::generate();
7461 become_local(&me);
7462 let community = saved_community_owned_by(&owner);
7463 let cid = community.id.to_hex();
7464 crate::db::community::set_community_dissolved(&cid).unwrap();
7465
7466 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &[0xCDu8; 32]);
7467 assert!(apply_server_root_rekey(&community, &parsed).is_err(),
7468 "a base rekey cannot cross a tombstone");
7469 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
7470 crate::community::Epoch(0), "base epoch did not advance");
7471 }
7472
7473 #[tokio::test]
7474 async fn tombstone_detected_after_a_base_rotation() {
7475 let (_tmp, _guard) = init_test_db();
7476 let relay = MemoryRelay::new();
7477 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7478 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7479 let cid = community.id.to_hex();
7480 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
7483 let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
7484 assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
7485 publish_tombstone(&relay, &rotated, &owner, 1000).await;
7486
7487 fetch_and_apply_control(&relay, &rotated).await.unwrap();
7488 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
7489 "tombstone at the rotation-stable locator is detected post-rotation");
7490 }
7491
7492 #[tokio::test]
7493 async fn stable_coordinate_tombstone_survives_a_concurrent_rotation() {
7494 let (_tmp, _guard) = init_test_db();
7499 let relay = MemoryRelay::new();
7500 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7501 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7502 let cid = community.id.to_hex();
7503 let inner = crate::community::roster::build_group_dissolved_edition_unsigned(owner.public_key(), &community.id, 1000)
7505 .sign_with_keys(&owner).unwrap();
7506 let stable = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id).unwrap();
7507 relay.inject(&stable, &community.relays);
7508 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
7511 let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
7512 assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
7513 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "not folded yet");
7514 fetch_and_apply_control(&relay, &rotated).await.unwrap();
7517 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
7518 "stable-coordinate probe discovers the tombstone cross-epoch (C3 closed)");
7519 }
7520}