1use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp};
15
16use super::super::transport::{Query, Transport};
17use super::super::{version, ChannelId, Epoch};
18use super::chat::{self, ChatEvent};
19use super::community::{ChannelV2, CommunityV2};
20use super::control;
21use super::derive::{base_rekey_group_key, channel_group_key, channel_rekey_group_key, GroupKey};
22use super::invite::{self, CommunityInvite};
23use super::rekey::{self, Continuity, RekeyScope};
24use super::{guestbook, stream, vsk};
25use crate::community::edition::ParsedEdition;
26
27fn me_pk() -> Result<PublicKey, String> {
38 crate::state::my_public_key().ok_or_else(|| "no active identity".to_string())
39}
40
41fn now_ms() -> u64 {
42 std::time::SystemTime::now()
43 .duration_since(std::time::UNIX_EPOCH)
44 .map(|d| d.as_millis() as u64)
45 .unwrap_or(0)
46}
47
48fn next_send_ms() -> u64 {
58 use std::sync::atomic::{AtomicU64, Ordering};
59 static LAST: AtomicU64 = AtomicU64::new(0);
60 let now = now_ms();
61 LAST.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |last| Some(now.max(last + 1)))
62 .map(|last| now.max(last + 1))
63 .unwrap_or(now)
64}
65
66pub async fn create_community<T: Transport + ?Sized>(
71 transport: &T,
72 name: &str,
73 relays: Vec<String>,
74 description: Option<String>,
75) -> Result<CommunityV2, String> {
76 crate::db::scoped(async move {
77 let signer = crate::signer::active_signer()?;
78 let owner_pk = me_pk()?;
79 let at_ms = now_ms();
80
81 let meta = control::CommunityMetadata {
82 name: name.to_string(),
83 description: description.clone(),
84 relays: relays.clone(),
85 ..Default::default()
86 };
87 let genesis = control::genesis_signed(owner_pk, &signer, meta, at_ms / 1000).await.map_err(|e| e.to_string())?;
88 let community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
89
90 let control = control::ControlPlane::of(&community);
100 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
101 for wrap in &genesis.wraps {
102 if let Ok((ed, _)) = control.open(wrap) {
103 let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
104 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
105 }
106 }
107 crate::db::community::save_community_v2(&community)?;
108 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
111
112 for wrap in &genesis.wraps {
117 transport.publish_durable(wrap, &community.relays).await?;
118 }
119
120 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
124 let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
125 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
126 let _ = transport.publish_durable(&join_wrap, &community.relays).await;
127 }
128
129 match republish_community_list(transport, Some(community.id())).await {
131 Ok(true) => {}
132 Ok(false) => republish_community_list_durable(Some(*community.id())),
133 Err(e) => {
134 crate::log_warn!("[CommunityList] failed to record this community across devices ({}) — retrying", e);
135 republish_community_list_durable(Some(*community.id()));
136 }
137 }
138 Ok(community)
139 })
140 .await
141}
142
143pub async fn create_migration_twin<T: Transport + ?Sized>(
148 transport: &T,
149 name: &str,
150 relays: Vec<String>,
151 description: Option<String>,
152 primary: (ChannelId, String),
153) -> Result<CommunityV2, String> {
154 crate::db::scoped(async move {
155 let signer = crate::signer::active_signer()?;
156 let owner_pk = me_pk()?;
157 let at_ms = now_ms();
158
159 let meta = control::CommunityMetadata {
160 name: name.to_string(),
161 description: description.clone(),
162 relays: relays.clone(),
163 ..Default::default()
164 };
165 let primary_name = primary.1.clone();
166 let genesis = control::genesis_signed_with_primary(owner_pk, &signer, meta, at_ms / 1000, Some(primary))
167 .await
168 .map_err(|e| e.to_string())?;
169 let mut community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
170 if let Some(ch) = community.channels.first_mut() {
173 ch.name = primary_name;
174 }
175 let control = control::ControlPlane::of(&community);
176 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
177 for wrap in &genesis.wraps {
178 if let Ok((ed, _)) = control.open(wrap) {
179 let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
180 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
181 }
182 }
183 crate::db::community::save_community_v2(&community)?;
184 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
185 for wrap in &genesis.wraps {
186 transport.publish_durable(wrap, &community.relays).await?;
187 }
188 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
190 let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
191 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
192 let _ = transport.publish_durable(&join_wrap, &community.relays).await;
193 }
194 Ok(community)
195 })
196 .await
197}
198
199pub async fn clone_banlist_to_twin<T: Transport + ?Sized>(
203 transport: &T,
204 twin: &CommunityV2,
205 banned: &[String],
206) -> Result<(), String> {
207 if banned.is_empty() {
208 return Ok(());
209 }
210 set_banlist(transport, twin, banned).await
211}
212
213pub async fn clone_governance_to_twin<T: Transport + ?Sized>(
224 transport: &T,
225 twin: &CommunityV2,
226 v1_roles: &crate::community::roles::CommunityRoles,
227 banned: &[String],
228) -> Result<(), String> {
229 use crate::community::roles::Permissions;
230 let owner = twin.owner()?;
231 for grant in &v1_roles.grants {
232 if !v1_roles.effective_permissions(&grant.member).contains(Permissions::ADMIN_FOUNDING_MASK) {
235 continue; }
237 if banned.contains(&grant.member) {
238 continue; }
240 let Ok(member) = PublicKey::parse(&grant.member) else { continue };
241 if member == owner {
242 continue; }
244 grant_admin(transport, twin, &member).await?;
245 }
246 Ok(())
247}
248
249pub fn twin_join_material(twin: &CommunityV2) -> super::list::JoinMaterial {
253 let mut jm = join_material(twin);
254 jm.control_root = None;
255 jm
256}
257
258pub async fn send_message<T: Transport + ?Sized>(
262 transport: &T,
263 community: &CommunityV2,
264 channel_id: &ChannelId,
265 content: &str,
266) -> Result<String, String> {
267 send_chat_message(transport, community, channel_id, content, None, &[], vec![]).await
268}
269
270pub async fn send_chat_message<T: Transport + ?Sized>(
274 transport: &T,
275 community: &CommunityV2,
276 channel_id: &ChannelId,
277 content: &str,
278 reply_to: Option<(&str, &str)>,
279 emoji: &[(&str, &str)],
280 extra_tags: Vec<nostr_sdk::prelude::Tag>,
281) -> Result<String, String> {
282 send_chat_message_at(transport, community, channel_id, content, reply_to, emoji, extra_tags, next_send_ms()).await
283}
284
285#[allow(clippy::too_many_arguments)]
290pub async fn send_chat_message_at<T: Transport + ?Sized>(
291 transport: &T,
292 community: &CommunityV2,
293 channel_id: &ChannelId,
294 content: &str,
295 reply_to: Option<(&str, &str)>,
296 emoji: &[(&str, &str)],
297 extra_tags: Vec<nostr_sdk::prelude::Tag>,
298 at_ms: u64,
299) -> Result<String, String> {
300 let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
301 let rumor = chat::build_message_rumor(author_pk, channel_id, epoch, content, reply_to, emoji, extra_tags, at_ms);
302 publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
303}
304
305#[allow(clippy::too_many_arguments)]
310pub async fn send_reaction<T: Transport + ?Sized>(
311 transport: &T,
312 community: &CommunityV2,
313 channel_id: &ChannelId,
314 target_id_hex: &str,
315 target_author_hex: &str,
316 target_kind: u16,
317 emoji_content: &str,
318 emoji: Option<(&str, &str)>,
319) -> Result<String, String> {
320 let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
321 let at_ms = next_send_ms();
322 let rumor =
323 chat::build_reaction_rumor(author_pk, channel_id, epoch, target_id_hex, target_author_hex, target_kind, emoji_content, emoji, at_ms);
324 publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
325}
326
327pub async fn send_edit<T: Transport + ?Sized>(
331 transport: &T,
332 community: &CommunityV2,
333 channel_id: &ChannelId,
334 target_id_hex: &str,
335 new_content: &str,
336) -> Result<String, String> {
337 let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
338 let at_ms = next_send_ms();
339 let rumor = chat::build_edit_rumor(author_pk, channel_id, epoch, target_id_hex, new_content, at_ms);
340 publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
341}
342
343pub async fn send_delete<T: Transport + ?Sized>(
347 transport: &T,
348 community: &CommunityV2,
349 channel_id: &ChannelId,
350 target_id_hex: &str,
351 target_kind: u16,
352) -> Result<String, String> {
353 let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
354 let at_ms = next_send_ms();
355 let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, None);
356 let id = publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await?;
357 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
362 spawn_pin_duty(&ch_hex, target_id_hex, None);
363 Ok(id)
364}
365
366pub async fn moderation_delete<T: Transport + ?Sized>(
376 transport: &T,
377 community: &CommunityV2,
378 channel_id: &ChannelId,
379 target_id_hex: &str,
380 target_kind: u16,
381 target_author: &PublicKey,
382) -> Result<String, String> {
383 let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
384 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
385 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
386 return Err("this community is dissolved — it accepts no new moderation actions".to_string());
387 }
388 let owner_hex = community.owner()?.to_hex();
389 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
390 if !crate::community::moderation::can_hide(
391 Some(&owner_hex),
392 &roster,
393 &author_pk.to_hex(),
394 &target_author.to_hex(),
395 ) {
396 return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
397 }
398 let at_ms = next_send_ms();
399 let citation = required_authority_citation(community, &author_pk)?;
400 let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, citation.as_ref());
401 let id = publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await?;
402 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
406 spawn_pin_duty(&ch_hex, target_id_hex, None);
407 Ok(id)
408}
409
410pub async fn send_webxdc_signal<T: Transport + ?Sized>(
417 transport: &T,
418 community: &CommunityV2,
419 channel_id: &ChannelId,
420 topic_id: &str,
421 node_addr: Option<&str>,
422) -> Result<(), String> {
423 let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
424 let at_ms = now_ms();
425 let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
426 let rumor = chat::build_webxdc_rumor(author_pk, channel_id, epoch, &content, vec![], at_ms);
427 publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await.map(|_| ())
428}
429
430pub async fn send_typing<T: Transport + ?Sized>(
432 transport: &T,
433 community: &CommunityV2,
434 channel_id: &ChannelId,
435) -> Result<(), String> {
436 let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
437 let at_ms = now_ms();
438 let rumor = chat::build_typing_rumor(author_pk, channel_id, epoch, at_ms);
439 publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
440}
441
442fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(PublicKey, GroupKey, Epoch), String> {
448 let author_pk = me_pk()?;
449 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
450 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
451 return Err("this community has been dissolved".to_string());
452 }
453 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author_pk.to_hex()) {
457 return Err("you are banned from this community".to_string());
458 }
459 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
460 if ch.private && ch.key.is_none() {
461 return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
462 }
463 let (secret, epoch) = community.channel_secret(ch);
464 Ok((author_pk, channel_group_key(&secret, channel_id, epoch), epoch))
465}
466
467#[allow(clippy::too_many_arguments)]
470fn own_echo_pin_duty(
476 outcome: &super::inbound::ChatPersist,
477 event: &ChatEvent,
478) -> Option<(String, super::stream::OpenedStream)> {
479 match (outcome, event) {
480 (super::inbound::ChatPersist::Updated { .. }, ChatEvent::Edit { opened, target, .. }) => {
481 Some((crate::simd::hex::bytes_to_hex_32(target), opened.clone()))
482 }
483 _ => None,
484 }
485}
486
487async fn publish_chat<T: Transport + ?Sized>(
488 transport: &T,
489 community: &CommunityV2,
490 group: &GroupKey,
491 author_pk: PublicKey,
492 channel_id: &ChannelId,
493 epoch: Epoch,
494 rumor: nostr_sdk::prelude::UnsignedEvent,
495 at_ms: u64,
496 ephemeral: bool,
497) -> Result<String, String> {
498 crate::db::scoped(async move {
499 let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
500 let signer = crate::signer::active_signer()?;
501 let (wrap, _p_tag_keys) = chat::seal_chat_rumor_signed(&signer, author_pk, &rumor, group, Timestamp::from_secs(at_ms / 1000), ephemeral).await
502 .map_err(|e| e.to_string())?;
503 transport.publish(&wrap, &community.relays).await?;
504 if !ephemeral {
510 crate::db::community::store_message_key(&rumor_id, &wrap.id.to_hex(), group.keys(), &community.relays)?;
511 }
512 if !ephemeral {
517 if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
518 let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
519 let outcome = {
520 let mut st = crate::state::STATE.lock().await;
521 super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author_pk)
522 };
523 if let Some(outcome) = outcome {
524 super::inbound::persist_chat(&channel_hex, &outcome).await;
525 if let Some((target_hex, opened)) = own_echo_pin_duty(&outcome, &event) {
526 spawn_pin_duty(&channel_hex, &target_hex, Some(opened));
527 }
528 }
529 }
530 }
531 Ok(rumor_id)
532 })
533 .await
534}
535
536pub struct FetchedEvent {
539 pub event: ChatEvent,
540 pub epoch: Epoch,
541}
542
543fn heal_own_wrap_key(event: &ChatEvent, group: &GroupKey, relays: &[String]) {
550 if !matches!(event, ChatEvent::Message { .. } | ChatEvent::Reaction { .. }) {
551 return;
552 }
553 let opened = event.opened();
554 if crate::state::my_public_key() != Some(opened.author) {
555 return;
556 }
557 let rumor_hex = opened.rumor_id.to_hex();
558 if !matches!(crate::db::community::get_message_key(&rumor_hex), Ok(None)) {
561 return;
562 }
563 if crate::db::community::store_message_key(&rumor_hex, &opened.wrapper_id.to_hex(), group.keys(), relays).is_ok() {
564 crate::traits::emit_event("message_delete_meta_changed", &serde_json::json!({ "id": rumor_hex }));
567 }
568}
569
570const LIVE_COORD_EPOCHS: usize = 1;
574
575pub async fn fetch_channel<T: Transport + ?Sized>(
579 transport: &T,
580 community: &CommunityV2,
581 channel_id: &ChannelId,
582 limit: usize,
583) -> Result<Vec<FetchedEvent>, String> {
584 fetch_channel_history(transport, community, channel_id, limit, 1, None, None, crate::community::transport::Evidence::Quorum, |_| true).await
585}
586
587pub async fn fetch_channel_history<T: Transport + ?Sized>(
601 transport: &T,
602 community: &CommunityV2,
603 channel_id: &ChannelId,
604 page: usize,
605 max_pages: usize,
606 since: Option<u64>,
607 start_until: Option<u64>,
610 evidence: crate::community::transport::Evidence,
611 mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
612) -> Result<Vec<FetchedEvent>, String> {
613 crate::db::scoped(async move {
614 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
617 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
622 let coords: Vec<([u8; 32], Epoch)> = if ch.private {
623 let Some(current) = ch.key else {
624 return Ok(Vec::new());
625 };
626 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
627 let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
628 if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
629 held.push((ch.epoch, current));
630 }
631 held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
634 } else {
635 let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
636 if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
637 roots.push((community.root_epoch, community.community_root));
638 }
639 roots.into_iter().map(|(ep, root)| (root, ep)).collect()
640 };
641 if coords.is_empty() {
642 return Ok(Vec::new());
643 }
644 let mut coords = coords;
651 if start_until.is_none() && coords.len() > LIVE_COORD_EPOCHS {
652 coords.sort_by_key(|(_, e)| std::cmp::Reverse(e.0));
653 coords.truncate(LIVE_COORD_EPOCHS);
654 }
655
656 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
657 let mut seen_rumors = std::collections::HashSet::new();
658 let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
659 let mut until: Option<u64> = start_until;
660 let mut oldest: Option<u64> = None;
661 for _ in 0..max_pages {
662 let mut wraps: Vec<Event> = Vec::new();
668 let mut wrap_ids: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
669 for (secret, epoch) in &coords {
670 let plane = channel_group_key(secret, channel_id, *epoch);
671 let q = Query {
672 kinds: vec![stream::KIND_WRAP],
673 authors: vec![plane.pk_hex()],
674 since,
675 until,
676 limit: Some(page),
677 evidence,
678 ..Default::default()
679 };
680 if let Ok(evs) = transport.fetch_plane(plane.keys(), &q, &community.relays).await {
681 for e in evs.events {
682 if wrap_ids.insert(e.id) {
683 wraps.push(e);
684 }
685 }
686 }
687 }
688 if wraps.is_empty() {
689 break;
690 }
691 let mut fresh = 0usize;
692 let mut page_events: Vec<FetchedEvent> = Vec::new();
693 for wrap in &wraps {
694 if !seen_wraps.insert(wrap.id) {
695 continue;
696 }
697 fresh += 1;
698 let at = wrap.created_at.as_secs();
699 if oldest.is_none_or(|o| at < o) {
700 oldest = Some(at);
701 }
702 for (secret, epoch) in &coords {
704 let group = channel_group_key(secret, channel_id, *epoch);
705 if wrap.pubkey != group.pk() {
706 continue;
707 }
708 if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
709 let id = event.opened().rumor_id;
710 if seen_rumors.insert(id) {
711 heal_own_wrap_key(&event, &group, &community.relays);
712 page_events.push(FetchedEvent { event, epoch: *epoch });
713 }
714 }
715 break;
716 }
717 }
718 if fresh == 0 {
719 if wraps.len() < page {
720 break; }
722 let Some(o) = oldest else { break };
726 if o == 0 {
727 break;
728 }
729 crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
730 until = Some(o - 1);
731 continue;
732 }
733 let stop = !page_events.is_empty() && !keep_paging(&page_events);
734 out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
735 if stop {
736 break; }
738 until = oldest; }
740 out.sort_by(|a, b| {
745 a.0.cmp(&b.0)
746 .then_with(|| a.1.event.opened().rumor_id.as_bytes().cmp(b.1.event.opened().rumor_id.as_bytes()))
747 });
748 Ok(out.into_iter().map(|(_, e)| e).collect())
749 })
750 .await
751}
752
753#[derive(Debug, Clone, Copy, PartialEq, Eq)]
762pub enum BundleAudience {
763 Link,
765 Member(PublicKey),
767}
768
769pub fn bundle_of(
775 community: &CommunityV2,
776 audience: BundleAudience,
777 creator: Option<PublicKey>,
778 expires_at_ms: Option<u64>,
779 label: Option<String>,
780) -> CommunityInvite {
781 bundle_of_with_overlay(community, audience, creator, expires_at_ms, label, &[], &[])
782}
783
784pub fn bundle_of_with_overlay(
788 community: &CommunityV2,
789 audience: BundleAudience,
790 creator: Option<PublicKey>,
791 expires_at_ms: Option<u64>,
792 label: Option<String>,
793 with: &[String],
794 without: &[String],
795) -> CommunityInvite {
796 let hex = crate::simd::hex::bytes_to_hex_32;
797 let cid_hex = hex(&community.identity.community_id.0);
798 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
799 let owner_hex = community.owner().ok().map(|o| o.to_hex());
800 let recipient_hex = match audience {
801 BundleAudience::Link => None,
802 BundleAudience::Member(pk) => Some(pk.to_hex()),
803 };
804 let channels = community
805 .vendable_channels(&roster, owner_hex.as_deref(), recipient_hex.as_deref(), with, without)
806 .into_iter()
807 .map(|c| invite::ChannelGrant {
808 id: hex(&c.id.0),
809 key: hex(&c.key.unwrap_or(community.community_root)),
810 epoch: c.epoch.0,
811 name: c.name.clone(),
812 })
813 .collect();
814 CommunityInvite {
815 community_id: hex(&community.identity.community_id.0),
816 owner: hex(&community.identity.owner_xonly),
817 owner_salt: hex(&community.identity.owner_salt),
818 community_root: hex(&community.community_root),
819 root_epoch: community.root_epoch.0,
820 control_pk: community.control_pk.map(|p| p.to_hex()),
823 channels,
824 relays: community.relays.clone(),
825 name: community.name.clone(),
826 icon: community.icon.clone(),
829 expires_at: expires_at_ms,
830 creator_npub: creator.map(|p| p.to_hex()),
831 label,
832 extra: Default::default(),
833 }
834}
835
836pub async fn send_direct_invite<T: Transport + ?Sized>(
842 transport: &T,
843 community: &CommunityV2,
844 recipient: &PublicKey,
845 expires_at_ms: Option<u64>,
846 label: Option<String>,
847) -> Result<Event, String> {
848 crate::db::scoped(async move {
849 assert_current_root(community)?;
852 let signer = crate::signer::active_signer()?;
853 let inviter_pk = me_pk()?;
854 let bundle = bundle_of(community, BundleAudience::Member(*recipient), Some(inviter_pk), expires_at_ms, label);
855 let wrap = invite::build_direct_invite_signed(&signer, inviter_pk, recipient, &bundle).await.map_err(|e| e.to_string())?;
856 transport.publish(&wrap, &community.relays).await?;
857 Ok(wrap)
858 })
859 .await
860}
861
862pub struct MintedLink {
866 pub url: String,
867 pub bundle_event: Event,
868 pub link_signer: Keys,
869 pub token: [u8; super::derive::TOKEN_LEN],
870 pub expires_at_ms: Option<u64>,
875 pub label: Option<String>,
876}
877
878pub async fn mint_public_link<T: Transport + ?Sized>(
884 transport: &T,
885 community: &CommunityV2,
886 base: &str,
887 expires_at_ms: Option<u64>,
888 label: Option<String>,
889) -> Result<MintedLink, String> {
890 crate::db::scoped(async move {
891 ensure_folded_permission(community, &me_pk()?, crate::community::roles::Permissions::CREATE_INVITE, "minting a public invite link")?;
894 let mut token = [0u8; super::derive::TOKEN_LEN];
895 token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
896 let link_signer = Keys::generate();
897 let bundle = bundle_of(community, BundleAudience::Link, Some(me_pk()?), expires_at_ms, label.clone());
898 let bundle_key = super::derive::invite_bundle_key(&token);
899 let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
900 let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
901
902 transport.publish_durable(&bundle_event, &community.relays).await?;
903 let minted = MintedLink { url, bundle_event, link_signer, token, expires_at_ms, label: label.clone() };
904 let _ = record_minted_link(transport, community, &minted).await;
908 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
912 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
913 let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
914 Ok(minted)
915 })
916 .await
917}
918
919async fn fetch_invite_list<T: Transport + ?Sized>(
928 transport: &T,
929 relays: &[String],
930) -> Result<Option<invite::InviteList>, String> {
931 let signer = crate::signer::active_signer()?;
932 let my_pk = me_pk()?;
933 let query = Query {
934 kinds: vec![super::kind::INVITE_LIST],
935 authors: vec![my_pk.to_hex()],
936 limit: Some(4),
937 evidence: crate::community::transport::Evidence::Full,
938 ..Default::default()
939 };
940 let events = transport.fetch(&query, relays).await?;
941 let mut best: Option<(u64, invite::InviteList)> = None;
942 for e in events {
943 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
944 let at = e.created_at.as_secs();
945 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
946 best = Some((at, l));
947 }
948 }
949 }
950 Ok(best.map(|(_, l)| l))
951}
952
953fn live_signers_for(list: &invite::InviteList, community_id_hex: &str, now_ms: u64) -> Vec<PublicKey> {
961 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
962 list.entries
963 .iter()
964 .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
965 .filter(|e| !e.expires_at.is_some_and(|exp| now_ms > exp))
966 .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
967 .collect()
968}
969
970async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, __session: &std::sync::Arc<crate::db::Session>, live_signers: &[PublicKey]) -> Result<(), String> {
974 let my_pk = me_pk()?;
975 let eid = super::derive::invite_links_locator(community.id(), &my_pk.to_bytes());
976 let content = invite::build_registry_content(live_signers);
977 publish_control_edition(transport, community, vsk::INVITE_LINKS, &eid, &content).await?;
978 refresh_invite_registry_cache(transport, community).await;
982 Ok(())
983}
984
985async fn refresh_invite_registry_cache<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) {
989 crate::db::scoped(async move {
990 let Ok(owner) = community.owner() else { return };
991 let Some(editions) = fetch_control_plane_whole(transport, community).await else { return };
992 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
993 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
994 .unwrap_or_default()
995 .into_iter()
996 .filter(|(_, f)| f.0 == community.root_epoch.0)
997 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
998 .collect();
999 let authority = fold_authority(community, &editions, &floors);
1000 let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
1001 let _ = crate::db::community::set_community_invite_registry(&cid_hex, &flatten_link_sets(&sets));
1002 let _ = crate::db::community::replace_invite_link_sets(&cid_hex, &sets);
1003 })
1004 .await
1005}
1006
1007async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
1010 crate::db::scoped(async move {
1011 let session = crate::db::current_session();
1012 let signer = crate::signer::active_signer()?;
1013 let my_pk = me_pk()?;
1014 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1015 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
1016 let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
1021 if !list.entries.iter().any(|e| e.token == token_hex) {
1022 list.entries.push(invite::InviteEntry {
1023 token: token_hex,
1024 signer_sk: minted.link_signer.secret_key().to_secret_hex(),
1025 community_id: cid_hex.clone(),
1026 url: minted.url.clone(),
1027 label: minted.label.clone(),
1028 created_at: now_ms() / 1000,
1029 expires_at: minted.expires_at_ms,
1030 epoch: Some(community.root_epoch.0),
1031 extra: Default::default(),
1032 });
1033 }
1034 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1035 transport.publish(&event, &community.relays).await?;
1036 let signers = live_signers_for(&list, &cid_hex, now_ms());
1037 publish_invite_registry(transport, community, &session, &signers).await
1038 })
1039 .await
1040}
1041
1042pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
1048 crate::db::scoped(async move {
1049 let session = crate::db::current_session();
1050 let signer = crate::signer::active_signer()?;
1051 let my_pk = me_pk()?;
1052 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1053 let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
1054 let entry = list.entries.iter().find(|e| e.token == token_hex && e.community_id == cid_hex).cloned();
1055 let Some(entry) = entry else {
1056 let tombstoned = list.tombstones.iter().any(|t| t.token == token_hex && t.community_id == cid_hex);
1063 crate::log_warn!(
1064 "[Invites] link {} is not in the fetched invite list ({}) — pruning the local record",
1065 &token_hex[..token_hex.len().min(8)],
1066 if tombstoned { "already tombstoned" } else { "retired elsewhere" }
1067 );
1068 let _ = crate::db::community::delete_public_invite(token_hex);
1069 return Ok(());
1070 };
1071 let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
1073 let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
1074 transport.publish_durable(&revocation, &community.relays).await?;
1075 list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
1077 list.entries.retain(|e| e.token != token_hex);
1078 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1079 transport.publish(&event, &community.relays).await?;
1080 let signers = live_signers_for(&list, &cid_hex, now_ms());
1081 let others_vend = if signers.is_empty() {
1089 match fetch_control_plane_whole(transport, community).await {
1090 Some(editions) => {
1091 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1092 .unwrap_or_default()
1093 .into_iter()
1094 .filter(|(_, f)| f.0 == community.root_epoch.0)
1095 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1096 .collect();
1097 let owner_hex = community.owner().map(|o| o.to_hex()).unwrap_or_default();
1098 let authority = fold_authority(community, &editions, &floors);
1099 live_invite_link_sets(community.id(), &owner_hex, &editions, &authority, &floors)
1100 .iter()
1101 .any(|s| s.creator_hex != my_pk.to_hex())
1102 }
1103 None => true,
1108 }
1109 } else {
1110 false
1111 };
1112 publish_invite_registry(transport, community, &session, &signers).await?;
1113 let _ = crate::db::community::delete_public_invite(token_hex);
1114 if signers.is_empty() && !others_vend {
1128 let mut rotated = false;
1129 for attempt in 0..3u8 {
1130 match refound_community(transport, community, &[]).await {
1131 Ok(_) => {
1132 rotated = true;
1133 break;
1134 }
1135 Err(e) if attempt == 2 => {
1136 crate::log_warn!(
1137 "[v2] privatizing {} could not rotate the base key ({e}); the community reads Private but everyone who took a link keeps read access until it rotates",
1138 &cid_hex[..8.min(cid_hex.len())]
1139 );
1140 crate::emit_event(
1141 "community_privatize_rotation_failed",
1142 &serde_json::json!({ "community_id": cid_hex, "error": e }),
1143 );
1144 }
1145 Err(_) => continue,
1146 }
1147 }
1148 if rotated {
1149 crate::log_info!("[v2] {} privatized: base key rotated so link-joined readers are cut off", &cid_hex[..8.min(cid_hex.len())]);
1150 }
1151 }
1152 Ok(())
1153 })
1154 .await
1155}
1156
1157pub(crate) fn sever_marks(cid_hex: &str) -> std::collections::BTreeMap<u64, u64> {
1163 crate::db::settings::get_sql_setting(format!("v2_sever:{cid_hex}"))
1164 .ok()
1165 .flatten()
1166 .and_then(|v| serde_json::from_str(&v).ok())
1167 .unwrap_or_default()
1168}
1169
1170pub(crate) fn epoch_minters(cid_hex: &str) -> std::collections::BTreeMap<u64, String> {
1175 crate::db::settings::get_sql_setting(format!("v2_minter:{cid_hex}"))
1176 .ok()
1177 .flatten()
1178 .and_then(|v| serde_json::from_str(&v).ok())
1179 .unwrap_or_default()
1180}
1181
1182pub(crate) fn record_epoch_minter(cid_hex: &str, epoch: u64, rotator_hex: &str) {
1186 let mut m = epoch_minters(cid_hex);
1187 if m.get(&epoch).is_some_and(|held| held == rotator_hex) {
1188 return;
1189 }
1190 m.insert(epoch, rotator_hex.to_string());
1191 if let Ok(v) = serde_json::to_string(&m) {
1192 let _ = crate::db::settings::set_sql_setting(format!("v2_minter:{cid_hex}"), v);
1193 }
1194}
1195
1196pub(crate) fn add_sever_mark(cid_hex: &str, epoch: u64, at_secs: u64) {
1199 let mut marks = sever_marks(cid_hex);
1200 marks.insert(epoch, at_secs);
1201 if let Ok(v) = serde_json::to_string(&marks) {
1202 let _ = crate::db::settings::set_sql_setting(format!("v2_sever:{cid_hex}"), v);
1203 }
1204}
1205
1206async fn sever_own_links<T: Transport + ?Sized>(
1219 transport: &T,
1220 community: &CommunityV2,
1221) -> Result<usize, String> {
1222 crate::db::scoped(async move {
1223 let session = crate::db::current_session();
1224 let signer = crate::signer::active_signer()?;
1225 let my_pk = me_pk()?;
1226 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1227 let Some(condemn_below) = sever_marks(&cid_hex).keys().copied().max() else {
1228 return Ok(0); };
1230 let maybe_mine = crate::db::community::invite_link_set_creators(&cid_hex)
1234 .map(|cs| cs.contains(&my_pk.to_hex()))
1235 .unwrap_or(true)
1236 || !crate::db::community::list_public_invites(&cid_hex).unwrap_or_default().is_empty();
1237 if !maybe_mine {
1238 return Ok(0);
1239 }
1240 let Some(mut list) = fetch_invite_list(transport, &community.relays).await? else {
1241 return Ok(0); };
1243 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1244 let victims: Vec<invite::InviteEntry> = list
1245 .entries
1246 .iter()
1247 .filter(|e| e.community_id == cid_hex && !dead.contains(e.token.as_str()) && e.epoch.unwrap_or(0) < condemn_below)
1248 .cloned()
1249 .collect();
1250 if victims.is_empty() {
1251 return Ok(0);
1252 }
1253 let mut revoked_tokens: Vec<String> = Vec::with_capacity(victims.len());
1254 for entry in &victims {
1255 match Keys::parse(&entry.signer_sk) {
1259 Ok(link_signer) => {
1260 let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
1261 transport.publish_durable(&revocation, &community.relays).await?;
1262 }
1263 Err(_) => crate::log_warn!("[Sever] link {} carries a malformed signer — tombstoning the husk", &entry.token[..entry.token.len().min(8)]),
1264 }
1265 list.tombstones.push(invite::InviteTombstone {
1266 token: entry.token.clone(),
1267 community_id: cid_hex.clone(),
1268 extra: Default::default(),
1269 });
1270 revoked_tokens.push(entry.token.clone());
1271 }
1272 list.entries.retain(|e| !revoked_tokens.contains(&e.token));
1273 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1274 transport.publish(&event, &community.relays).await?;
1275 let signers = live_signers_for(&list, &cid_hex, now_ms());
1276 publish_invite_registry(transport, community, &session, &signers).await?;
1277 for token in &revoked_tokens {
1278 let _ = crate::db::community::delete_public_invite(token);
1279 }
1280 crate::log_info!("[Sever] revoked {} own public link(s) (containment)", revoked_tokens.len());
1281 Ok(revoked_tokens.len())
1282 })
1283 .await
1284}
1285
1286pub(crate) async fn retry_pending_severance<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) {
1291 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1292 if sever_marks(&cid_hex).is_empty() {
1293 return;
1294 }
1295 if let Err(e) = sever_own_links(transport, community).await {
1296 crate::log_warn!("[Sever] pending link severance retry failed (will retry next follow): {e}");
1297 }
1298}
1299
1300pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1305 crate::db::scoped(async move {
1306 let session = crate::db::current_session();
1307 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1308 let signer = crate::signer::active_signer()?;
1313 let my_pk = me_pk()?;
1314 let query = Query {
1315 kinds: vec![super::kind::INVITE_LIST],
1316 authors: vec![my_pk.to_hex()],
1317 limit: Some(4),
1318 ..Default::default()
1319 };
1320 let events = transport.fetch(&query, &community.relays).await?;
1321 let mut best: Option<(u64, invite::InviteList)> = None;
1322 for e in events {
1323 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
1324 let at = e.created_at.as_secs();
1325 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
1326 best = Some((at, l));
1327 }
1328 }
1329 }
1330 let Some((_, list)) = best else {
1331 return Ok(());
1332 };
1333 let creator = my_pk;
1334 let now = now_ms();
1335 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1336 let condemn_below = sever_marks(&cid_hex).keys().copied().max().unwrap_or(0);
1341 for entry in &list.entries {
1342 if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
1343 continue;
1344 }
1345 if entry.epoch.unwrap_or(0) < condemn_below {
1346 continue;
1347 }
1348 if entry.expires_at.is_some_and(|exp| now > exp) {
1351 continue;
1352 }
1353 let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
1354 let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
1355 let bundle = bundle_of(community, BundleAudience::Link, Some(creator), entry.expires_at, entry.label.clone());
1356 let bundle_key = super::derive::invite_bundle_key(&token);
1357 if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
1358 let _ = transport.publish_durable(&event, &community.relays).await;
1359 }
1360 }
1361 let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
1371 if !mine_here {
1372 return Ok(());
1373 }
1374 let signers = live_signers_for(&list, &cid_hex, now);
1375 let _ = publish_invite_registry(transport, community, &session, &signers).await;
1376 Ok(())
1377 })
1378 .await
1379}
1380
1381pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1386 let Ok(owner) = community.owner() else { return false };
1387 let Some(editions) = fetch_control_plane_whole(transport, community).await else { return true };
1391 let cid = community.id();
1392 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1393 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1394 .unwrap_or_default()
1395 .into_iter()
1396 .filter(|(_, f)| f.0 == community.root_epoch.0)
1397 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1398 .collect();
1399 let authority = fold_authority(community, &editions, &floors);
1400 !live_invite_link_sets(cid, &owner.to_hex(), &editions, &authority, &floors).is_empty()
1401}
1402
1403async fn fetch_control_plane_whole<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Option<Vec<ParsedEdition>> {
1409 let control = control::ControlPlane::of(community);
1410 let mut editions: Vec<ParsedEdition> = Vec::new();
1411 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1412 let mut oldest: Option<u64> = None;
1413 let mut until: Option<u64> = None;
1414 for page in 0..COMPACT_MAX_PAGES {
1415 let query = Query {
1422 kinds: vec![stream::KIND_WRAP],
1423 authors: vec![control.pk_hex()],
1424 until,
1425 limit: Some(FOLLOW_PAGE),
1426 evidence: crate::community::transport::Evidence::Full,
1427 ..Default::default()
1428 };
1429 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return None };
1430 let mut fresh = 0usize;
1431 for w in &wraps {
1432 if !seen_wraps.insert(w.id) {
1433 continue;
1434 }
1435 fresh += 1;
1436 let at = w.created_at.as_secs();
1437 if oldest.is_none_or(|o| at < o) {
1438 oldest = Some(at);
1439 }
1440 if let Ok((ed, _)) = control.open(w) {
1441 editions.push(ed);
1442 }
1443 }
1444 if fresh == 0 {
1445 if wraps.len() >= FOLLOW_PAGE {
1446 return None; }
1448 return Some(editions);
1449 }
1450 until = oldest;
1451 if page + 1 == COMPACT_MAX_PAGES {
1452 return None;
1453 }
1454 }
1455 Some(editions)
1456}
1457
1458fn live_invite_link_sets(
1463 cid: &crate::community::CommunityId,
1464 owner_hex: &str,
1465 editions: &[ParsedEdition],
1466 authority: &AuthoritySet,
1467 floors: &Floors,
1468) -> Vec<crate::db::community::InviteLinkSetRow> {
1469 use crate::community::roles::Permissions;
1470 use std::collections::BTreeMap;
1471 let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
1472 for e in editions {
1473 if e.vsk == vsk::INVITE_LINKS {
1474 by_eid.entry(e.entity_id).or_default().push(e);
1475 }
1476 }
1477 let mut sets: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1478 for (eid, group) in &by_eid {
1479 let authed: Vec<&ParsedEdition> = group
1485 .iter()
1486 .copied()
1487 .filter(|p| {
1488 let author = p.author.to_hex();
1489 !authority.banned.contains(&author)
1491 && authority.roles.is_authorized(&author, Some(owner_hex), Permissions::CREATE_INVITE)
1492 && super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
1493 })
1494 .collect();
1495 if authed.is_empty() {
1496 continue;
1497 }
1498 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
1499 let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
1500 if let Ok(signers) = invite::parse_registry_content(&authed[hi].content) {
1501 if signers.is_empty() {
1502 continue; }
1504 sets.push(crate::db::community::InviteLinkSetRow {
1505 creator_hex: authed[hi].author.to_hex(),
1506 locators: signers.iter().map(|p| p.to_hex()).collect(),
1507 });
1508 }
1509 }
1510 sets
1511}
1512
1513fn flatten_link_sets(sets: &[crate::db::community::InviteLinkSetRow]) -> Vec<String> {
1515 let mut flat: Vec<String> = sets.iter().flat_map(|s| s.locators.iter().cloned()).collect();
1516 flat.sort();
1517 flat.dedup();
1518 flat
1519}
1520
1521async fn accept_bundle<T: Transport + ?Sized>(
1527 transport: &T,
1528 bundle: &CommunityInvite,
1529 invited_by: Option<PublicKey>,
1530 announce_join: bool,
1531) -> Result<CommunityV2, String> {
1532 crate::db::scoped(async move {
1533 let signer = crate::signer::active_signer()?;
1534 let my_pk = me_pk()?;
1535 let at_ms = now_ms();
1536 if bundle.expired(at_ms) {
1538 return Err("this invite has expired".to_string());
1539 }
1540 let community = CommunityV2::from_bundle(bundle, at_ms)?;
1542 let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
1545
1546 let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
1555 v.session.is_live()
1556 && v.at.elapsed() < VERIFIED_PREVIEW_TTL
1557 && v.community_id == community.id().0
1558 && v.community_root == community.community_root
1559 });
1560 let (community, join_heads, join_banlist, join_pins, join_banlist_content, join_roles, join_authority_heads) =
1561 match handoff {
1562 Some(v) => {
1563 let mut c = v.folded;
1564 c.created_at_ms = at_ms;
1566 (c, v.heads, v.banned, v.pins, v.banlist_persist, v.roles, v.authority_heads)
1567 }
1568 None => {
1569 let vj = verify_owner_root_and_reconcile(transport, community).await?;
1570 (vj.community, vj.heads, vj.banned, vj.pins, vj.banlist_persist, vj.roles, vj.authority_heads)
1571 }
1572 };
1573
1574 if is_dissolved(transport, &community).await {
1576 return Err(ERR_DISSOLVED.to_string());
1577 }
1578
1579 if join_banlist.contains(&my_pk.to_hex()) {
1584 return Err("you are banned from this community".to_string());
1585 }
1586
1587 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1595 for h in join_heads.iter().chain(join_authority_heads.iter()) {
1596 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, community.root_epoch.0)?;
1597 }
1598 crate::db::community::save_community_v2(&community)?;
1599 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
1602 for ch in &community.channels {
1605 if let (true, Some(key)) = (ch.private, ch.key) {
1606 let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
1607 }
1608 }
1609 if let Some((banned_list, version)) = &join_banlist_content {
1615 let _ = crate::db::community::set_community_banlist(&cid_hex, banned_list, *version as i64);
1616 }
1617 if let Some((roles, at)) = &join_roles {
1623 let _ = crate::db::community::set_community_roles(&cid_hex, roles, *at);
1624 }
1625 for (channel_hex, content, version, _author, _at) in &join_pins {
1626 if matches!(
1627 crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64),
1628 Ok(true)
1629 ) {
1630 crate::emit_event(
1631 "community_pins_updated",
1632 &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
1633 );
1634 }
1635 }
1636
1637 if announce_join && !already_held {
1643 let attribution = invited_by
1644 .map(|p| p.to_hex())
1645 .or_else(|| bundle.creator_npub.clone())
1646 .zip(Some(bundle.label.clone().unwrap_or_default()));
1647 let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
1648 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1649 let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
1650 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1651 let _ = transport.publish(&join_wrap, &community.relays).await;
1652 }
1653 }
1654
1655 if announce_join {
1665 match republish_community_list(transport, Some(community.id())).await {
1666 Ok(true) => {}
1667 Ok(false) => republish_community_list_durable(Some(*community.id())),
1668 Err(e) => {
1669 crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
1670 republish_community_list_durable(Some(*community.id()));
1671 }
1672 }
1673 }
1674 Ok(community)
1675 })
1676 .await
1677}
1678
1679struct VerifiedJoin {
1701 community: CommunityV2,
1702 heads: Vec<FoldedHead>,
1703 banned: std::collections::BTreeSet<String>,
1705 pins: Vec<(String, String, u64, String, u64)>,
1708 banlist_persist: Option<(Vec<String>, u64)>,
1710 roles: Option<(crate::community::roles::CommunityRoles, i64)>,
1718 authority_heads: Vec<FoldedHead>,
1722}
1723
1724async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
1725 transport: &T,
1726 community: CommunityV2,
1727) -> Result<VerifiedJoin, String> {
1728 let owner = community.owner()?;
1729 let control = control::ControlPlane::of(&community);
1730 let control_pk = control.pk_hex();
1731
1732 super::streamauth::prime(&community);
1739
1740 const PAGE: usize = 500;
1761 const MAX_PAGES: usize = 4;
1762 const FAR_FUTURE_SECS: u64 = 4_102_444_800; let mut editions: Vec<ParsedEdition> = Vec::new();
1764 let mut all_editions: Vec<ParsedEdition> = Vec::new();
1765 let mut found_genesis = false;
1766 let mut compacted_metadata = false;
1774 crate::log_debug!(
1775 "[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
1776 &control_pk[..12], community.root_epoch, community.relays
1777 );
1778 let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
1779 found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
1780 };
1781 for attempt in 0..2 {
1782 editions.clear();
1783 all_editions.clear();
1784 compacted_metadata = false;
1785 let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1786 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1787 for page_no in 0..MAX_PAGES {
1788 let query = Query {
1789 kinds: vec![stream::KIND_WRAP],
1790 authors: vec![control_pk.clone()],
1791 until,
1792 limit: Some(PAGE),
1793 evidence: crate::community::transport::Evidence::Full,
1794 ..Default::default()
1795 };
1796 let wraps = transport.fetch(&query, &community.relays).await?;
1797 crate::log_trace!(
1798 "[JoinVerify] attempt {} page {}: fetched {} wraps",
1799 attempt, page_no, wraps.len()
1800 );
1801 let mut oldest = u64::MAX;
1805 let mut fresh = 0usize;
1806 for w in &wraps {
1807 if !seen_wraps.insert(w.id) {
1808 continue;
1809 }
1810 fresh += 1;
1811 oldest = oldest.min(w.created_at.as_secs());
1812 if let Ok((ed, _)) = control.open(w) {
1813 crate::log_trace!(
1814 "[JoinVerify] edition vsk={} eid={} owner={} at={}",
1815 ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
1816 ed.author == owner, w.created_at.as_secs()
1817 );
1818 if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1819 if ed.author == owner {
1820 found_genesis = true;
1821 } else {
1822 compacted_metadata = true;
1823 }
1824 }
1825 if ed.author == owner {
1826 editions.push(ed.clone());
1827 }
1828 all_editions.push(ed);
1831 }
1832 }
1833 crate::log_debug!(
1834 "[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
1835 attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
1836 );
1837 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
1838 break; }
1840 until = Some(oldest);
1841 }
1842 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1843 break;
1844 }
1845 if attempt == 0 {
1846 if let Some(client) = crate::state::nostr_client() {
1853 super::streamauth::prime_auth(&client, &community.relays).await;
1854 }
1855 }
1856 }
1857 if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1858 return Err(
1859 "could not verify this community from its relays (the invite may be forged, the relays are unreachable, or the control plane is being flooded); not joining"
1860 .to_string(),
1861 );
1862 }
1863 let empty_floors = Floors::new();
1872 let authority = AuthoritySet::owner_only();
1873 let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1874 let authority_full = fold_authority(&community, &all_editions, &empty_floors);
1879 let pins = apply_control_fold(&community, &all_editions, &empty_floors, &authority_full).pins_persist;
1884 let newest_roster_at = all_editions
1887 .iter()
1888 .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
1889 .map(|e| e.created_at as i64)
1890 .max()
1891 .unwrap_or(0);
1892 let roles = (!authority_full.gapped).then(|| (authority_full.roles.clone(), newest_roster_at));
1896 Ok(VerifiedJoin {
1897 community: fold.updated.unwrap_or(community),
1898 heads: fold.heads,
1899 banned: authority_full.banned,
1900 pins,
1901 banlist_persist: authority_full.banlist_persist,
1902 roles,
1903 authority_heads: authority_full.heads,
1904 })
1905}
1906
1907pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1911 let signer = crate::signer::active_signer()?;
1912 let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
1913 accept_bundle(transport, &bundle, Some(inviter), true).await
1914}
1915
1916pub async fn accept_parked_invite<T: Transport + ?Sized>(
1922 transport: &T,
1923 bundle_json: &str,
1924 inviter_hex: Option<&str>,
1925) -> Result<CommunityV2, String> {
1926 let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1927 let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1928 accept_bundle(transport, &bundle, invited_by, true).await
1929}
1930
1931pub async fn accept_migration_material<T: Transport + ?Sized>(
1937 transport: &T,
1938 jm: &super::list::JoinMaterial,
1939) -> Result<CommunityV2, String> {
1940 let bundle = material_to_invite(jm);
1941 accept_bundle(transport, &bundle, None, true).await
1942}
1943
1944pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1951 let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1952 let query = Query {
1957 kinds: vec![super::kind::INVITE_BUNDLE],
1958 authors: vec![parsed.link_signer.to_hex()],
1959 ..Default::default()
1960 };
1961 let relays = if parsed.bootstrap_relays.is_empty() {
1962 invite::stock_relays()
1963 } else {
1964 parsed.bootstrap_relays.clone()
1965 };
1966 let events = match transport.fetch(&query, &relays).await {
1971 Ok(evs) => evs,
1972 Err(_) => {
1973 wait_for_bootstrap_relay(&relays).await;
1974 transport.fetch(&query, &relays).await?
1975 }
1976 };
1977 let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1978
1979 let mut newest_live: Option<(u64, CommunityInvite)> = None;
1981 for event in &events {
1982 match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1983 Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1984 Ok(invite::BundleState::Live(bundle)) => {
1985 let at = event.created_at.as_secs();
1986 if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1987 newest_live = Some((at, *bundle));
1988 }
1989 }
1990 Err(_) => {} }
1992 }
1993 newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1994}
1995
1996async fn wait_for_bootstrap_relay(relays: &[String]) {
2000 let Some(client) = crate::state::nostr_client() else { return };
2001 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8);
2002 loop {
2003 for url in relays {
2004 if let Ok(Some(relay)) = client.relay(url).await {
2005 if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
2006 return;
2007 }
2008 }
2009 }
2010 if tokio::time::Instant::now() >= deadline {
2011 return;
2012 }
2013 tokio::time::sleep(std::time::Duration::from_millis(400)).await;
2014 }
2015}
2016
2017struct VerifiedPreview {
2023 session: std::sync::Arc<crate::db::Session>,
2024 at: std::time::Instant,
2025 community_id: [u8; 32],
2026 community_root: [u8; 32],
2027 folded: CommunityV2,
2028 heads: Vec<FoldedHead>,
2029 banned: std::collections::BTreeSet<String>,
2032 pins: Vec<(String, String, u64, String, u64)>,
2035 banlist_persist: Option<(Vec<String>, u64)>,
2036 roles: Option<(crate::community::roles::CommunityRoles, i64)>,
2037 authority_heads: Vec<FoldedHead>,
2038}
2039static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
2040const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
2041
2042pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
2049 let bundle = fetch_public_bundle(transport, url).await?;
2050 preview_bundle(transport, &bundle).await
2051}
2052
2053pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
2058 let community = CommunityV2::from_bundle(bundle, 0)?;
2059 match verify_owner_root_and_reconcile(transport, community.clone()).await {
2060 Ok(vj) => {
2061 let folded = vj.community;
2062 *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
2063 session: crate::db::current_session(),
2064 at: std::time::Instant::now(),
2065 community_id: folded.id().0,
2066 community_root: folded.community_root,
2067 folded: folded.clone(),
2068 heads: vj.heads,
2069 banned: vj.banned,
2070 pins: vj.pins,
2071 banlist_persist: vj.banlist_persist,
2072 roles: vj.roles,
2073 authority_heads: vj.authority_heads,
2074 });
2075 Ok(folded)
2076 }
2077 Err(_) => Ok(community),
2078 }
2079}
2080
2081pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
2083 crate::db::scoped(async move {
2084 let bundle = fetch_public_bundle(transport, url).await?;
2086 accept_bundle(transport, &bundle, None, true).await
2087 })
2088 .await
2089}
2090
2091pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2093 crate::db::scoped(async move {
2094 let signer = crate::signer::active_signer()?;
2095 let my_pk = me_pk()?;
2096 let at_ms = now_ms();
2097 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2098 let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
2099 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
2100 let _ = transport.publish(&wrap, &community.relays).await;
2101 }
2102 if let Err(e) = tombstone_community_list(transport, community.id(), &community.relays, at_ms).await {
2106 crate::log_net_fail!("[CommunityList] leave tombstone failed ({}) — retrying in the background", e);
2107 tombstone_community_list_durable(*community.id(), community.relays.clone(), at_ms);
2108 }
2109 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2110 Ok(())
2111 })
2112 .await
2113}
2114
2115pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
2120 crate::db::scoped(async move {
2121 assert_current_root(community)?;
2122 let signer = crate::signer::active_signer()?;
2123 let my_pk = me_pk()?;
2124 let authority = fetch_authority(transport, community).await;
2126 let owner_hex = community.owner()?.to_hex();
2127 if !authority.roles.can_act_on_member(
2128 &my_pk.to_hex(),
2129 Some(&owner_hex),
2130 &target.to_hex(),
2131 crate::community::roles::Permissions::KICK,
2132 ) {
2133 return Err("not authorized to kick this member".to_string());
2134 }
2135 let target_hex = target.to_hex();
2147 let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
2148 let may_strip = authority.roles.can_act_on_member(
2149 &my_pk.to_hex(),
2150 Some(&owner_hex),
2151 &target_hex,
2152 crate::community::roles::Permissions::MANAGE_ROLES,
2153 );
2154 if holds_roles && may_strip {
2155 grant_roles(transport, community, target, Vec::new())
2156 .await
2157 .map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
2158 }
2159 let at_ms = now_ms();
2160 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2161 let citation = required_authority_citation(community, &my_pk)?;
2164 let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
2165 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
2166 .map_err(|e| e.to_string())?;
2167 transport.publish(&wrap, &community.relays).await?;
2168 Ok(())
2169 })
2170 .await
2171}
2172
2173#[derive(Debug, Default)]
2175pub struct KickSweep {
2176 pub kicked: Vec<PublicKey>,
2177 pub refused: Vec<(PublicKey, String)>,
2180 pub failed: Vec<PublicKey>,
2182}
2183
2184pub async fn kick_members<T: Transport + ?Sized>(
2195 transport: &T,
2196 community: &CommunityV2,
2197 targets: &[PublicKey],
2198 on_progress: &(dyn Fn(usize, usize) + Sync),
2199) -> Result<KickSweep, String> {
2200 crate::db::scoped(async move {
2201 assert_current_root(community)?;
2202 let mut out = KickSweep::default();
2203 if targets.is_empty() {
2204 return Ok(out);
2205 }
2206 let signer = crate::signer::active_signer()?;
2207 let my_pk = me_pk()?;
2208 let my_hex = my_pk.to_hex();
2209 let owner_hex = community.owner()?.to_hex();
2210 let authority = fetch_authority(transport, community).await;
2213 let citation = required_authority_citation(community, &my_pk)?;
2214 let gb_group =
2215 super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2216
2217 let total = targets.len();
2218 for (i, target) in targets.iter().enumerate() {
2219 let target_hex = target.to_hex();
2220 let outranks = |perm: u64| {
2221 authority.roles.can_act_on_member(&my_hex, Some(&owner_hex), &target_hex, perm)
2222 };
2223 if !outranks(crate::community::roles::Permissions::KICK) {
2224 out.refused.push((*target, "you do not outrank this member".to_string()));
2225 on_progress(i + 1, total);
2226 continue;
2227 }
2228 let holds_roles =
2233 authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
2234 if holds_roles && outranks(crate::community::roles::Permissions::MANAGE_ROLES) {
2235 if let Err(e) = grant_roles(transport, community, target, Vec::new()).await {
2236 out.refused.push((*target, format!("could not strip roles before kicking: {e}")));
2237 on_progress(i + 1, total);
2238 continue;
2239 }
2240 }
2241 let at_ms = now_ms();
2242 let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
2243 match guestbook::seal_guestbook_rumor_signed(
2244 &signer,
2245 my_pk,
2246 &rumor,
2247 &gb_group,
2248 Timestamp::from_secs(at_ms / 1000),
2249 )
2250 .await
2251 {
2252 Ok((wrap, _)) => match transport.publish(&wrap, &community.relays).await {
2253 Ok(_) => out.kicked.push(*target),
2254 Err(_) => out.failed.push(*target),
2255 },
2256 Err(_) => out.failed.push(*target),
2257 }
2258 on_progress(i + 1, total);
2259 }
2260 Ok(out)
2261 })
2262 .await
2263}
2264
2265pub struct AuthorityView {
2271 pub roles: crate::community::roles::CommunityRoles,
2272 pub banned: std::collections::BTreeSet<String>,
2273 pub gapped: bool,
2275 pub floored: std::collections::BTreeSet<String>,
2277 pub head_entities: std::collections::BTreeSet<String>,
2279 pub banned_at: std::collections::BTreeMap<String, u64>,
2281}
2282
2283pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
2289 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2290 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
2291 .unwrap_or_default()
2292 .into_iter()
2293 .filter(|(_, f)| f.0 == community.root_epoch.0)
2294 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2295 .collect();
2296 let control = control::ControlPlane::of(community);
2297
2298 let mut editions: Vec<ParsedEdition> = Vec::new();
2299 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2300 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2301 let mut oldest: Option<u64> = None;
2302 let mut until: Option<u64> = None;
2303 let mut a = fold_authority(community, &[], &floors);
2308 for _ in 0..FOLLOW_MAX_PAGES {
2309 let query = Query {
2313 kinds: vec![stream::KIND_WRAP],
2314 authors: vec![control.pk_hex()],
2315 until,
2316 limit: Some(FOLLOW_PAGE),
2317 evidence: crate::community::transport::Evidence::Quorum,
2318 ..Default::default()
2319 };
2320 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
2321 let mut fresh = 0usize;
2322 for w in &wraps {
2323 if !seen_wraps.insert(w.id) {
2324 continue;
2325 }
2326 fresh += 1;
2327 let at = w.created_at.as_secs();
2328 if oldest.is_none_or(|o| at < o) {
2329 oldest = Some(at);
2330 }
2331 if let Ok((ed, _)) = control.open(w) {
2332 if seen.insert(ed.inner_id) {
2333 editions.push(ed);
2334 }
2335 }
2336 }
2337 a = fold_authority(community, &editions, &floors);
2338 if !a.gapped || fresh == 0 {
2339 break;
2340 }
2341 until = oldest;
2342 }
2343 AuthorityView {
2344 roles: a.roles,
2345 banned: a.banned,
2346 gapped: a.gapped,
2347 floored: floors.keys().cloned().collect(),
2348 head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
2349 banned_at: a.banned_at,
2350 }
2351}
2352
2353async fn fetch_guestbook_events<T: Transport + ?Sized>(
2365 transport: &T,
2366 community: &CommunityV2,
2367 since_secs: u64,
2368) -> Result<(Vec<guestbook::GuestbookEvent>, u64, bool), String> {
2369 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2370 const GB_PAGE: usize = 500;
2371 const GB_MAX_PAGES: usize = 12;
2372 let mut events = Vec::new();
2373 let mut newest: u64 = since_secs;
2374 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2375 let mut until: Option<u64> = None;
2376 let mut oldest: Option<u64> = None;
2377 let mut reached_end = false;
2381 for _ in 0..GB_MAX_PAGES {
2382 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_group.pk_hex()], until, limit: Some(GB_PAGE), evidence: crate::community::transport::Evidence::Full, ..Default::default() };
2385 let wraps = transport.fetch(&query, &community.relays).await?;
2386 let mut fresh = 0usize;
2387 for wrap in &wraps {
2388 if !seen.insert(wrap.id) {
2389 continue;
2390 }
2391 fresh += 1;
2392 let at = wrap.created_at.as_secs();
2393 if oldest.is_none_or(|o| at < o) {
2394 oldest = Some(at);
2395 }
2396 if at > newest {
2397 newest = at;
2398 }
2399 if at < since_secs {
2401 continue;
2402 }
2403 if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
2404 if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
2405 events.push(ev);
2406 }
2407 }
2408 }
2409 if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
2410 reached_end = true;
2411 break;
2412 }
2413 match oldest {
2414 Some(o) if o > 0 => until = Some(o),
2415 _ => {
2417 reached_end = true;
2418 break;
2419 }
2420 }
2421 }
2422 Ok((events, newest, reached_end))
2423}
2424
2425fn snapshot_authority_for(cid_hex: &str, community: &CommunityV2, owner: &PublicKey) -> Option<PublicKey> {
2444 if community.root_epoch.0 == 0 {
2445 return None;
2446 }
2447 let minted_by = epoch_minters(cid_hex)
2448 .get(&community.root_epoch.0)
2449 .and_then(|h| PublicKey::from_hex(h).ok());
2450 Some(minted_by.unwrap_or(*owner))
2451}
2452
2453fn fold_members(
2454 community: &CommunityV2,
2455 events: &[guestbook::GuestbookEvent],
2456 mut observed: std::collections::BTreeMap<PublicKey, u64>,
2457 roles: &crate::community::roles::CommunityRoles,
2458 banlist: &std::collections::BTreeSet<PublicKey>,
2459 banned_at: &std::collections::BTreeMap<PublicKey, u64>,
2460) -> Result<Vec<PublicKey>, String> {
2461 let owner = community.owner()?;
2462 let owner_hex = owner.to_hex();
2463 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2464
2465 for g in &roles.grants {
2472 if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
2473 observed.entry(pk).or_insert(0);
2474 }
2475 }
2476
2477 let snapshot_authority_pk = snapshot_authority_for(&cid_hex, community, &owner);
2486 let snapshot_authority = snapshot_authority_pk.as_ref();
2487 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2491 let actor_hex = actor.to_hex();
2492 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2493 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2494 };
2495 let now = now_ms();
2496 let coalesced = guestbook::coalesce(events, now, snapshot_authority, &can_kick);
2497 let refound = guestbook::live_refound(events, snapshot_authority, now);
2501 let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at, refound.as_ref());
2502 if !banlist.contains(&owner) {
2504 members.insert(owner);
2505 }
2506 Ok(members.into_iter().collect())
2507}
2508
2509
2510#[derive(Debug, Default, serde::Serialize)]
2512pub struct RescueReport {
2513 pub hops: usize,
2515 pub hops_skipped: usize,
2517 pub chunks_published: usize,
2518 pub targets: usize,
2519}
2520
2521pub async fn rescue_stranded_members<T: Transport + ?Sized>(
2549 transport: &T,
2550 community: &CommunityV2,
2551 targets: &[PublicKey],
2552) -> Result<RescueReport, String> {
2553 crate::db::scoped(async move {
2554 let mut report = RescueReport { targets: targets.len(), ..Default::default() };
2555 if targets.is_empty() {
2556 return Ok(report);
2557 }
2558 let signer = crate::signer::active_signer()?;
2559 let my_pk = me_pk()?;
2560 let cid = community.id();
2561 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2562 let owner = community.owner()?;
2563 let owner_hex = owner.to_hex();
2564 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2568 if my_pk != owner && !roster.is_authorized(&my_pk.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN) {
2569 return Err("rescuing stranded members requires rotation authority (owner or BAN)".to_string());
2570 }
2571 let control_pk = community.control_pk.ok_or("rescue requires a split community (no control pk held)")?;
2572 let citation = my_authority_citation(community, &my_pk);
2573
2574 let mut roots: std::collections::BTreeMap<u64, [u8; 32]> =
2577 crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)?
2578 .into_iter()
2579 .map(|(e, k)| (e.0, k))
2580 .collect();
2581 roots.insert(community.root_epoch.0, community.community_root);
2582
2583 let at_secs = now_ms() / 1000;
2584 for e in 1..=community.root_epoch.0 {
2585 let (Some(prev_root), Some(new_root)) = (roots.get(&(e - 1)).copied(), roots.get(&e).copied()) else {
2586 crate::log_warn!("[Rescue] no archived root for hop {} -> {} — members wedged there stay wedged", e - 1, e);
2587 report.hops_skipped += 1;
2588 continue;
2589 };
2590 let group = super::derive::base_rekey_group_key(&prev_root, cid, Epoch(e));
2591 let prev_commit = super::derive::epoch_key_commitment(Epoch(e - 1), &prev_root);
2592 let existing = fetch_rekey_chunks(transport, &community.relays, &group).await?;
2597 let start_after = existing
2598 .iter()
2599 .filter(|c| {
2600 c.rotator == my_pk
2601 && c.scope == super::rekey::RekeyScope::Root
2602 && c.new_epoch == Epoch(e)
2603 && c.prev_commit == prev_commit
2604 })
2605 .map(|c| c.chunk.0.max(c.chunk.1))
2606 .max()
2607 .unwrap_or(0);
2608 let mut recipients: Vec<PublicKey> = targets.to_vec();
2612 if !recipients.contains(&my_pk) {
2613 recipients.push(my_pk);
2614 }
2615 let mut blobs = Vec::with_capacity(recipients.len());
2616 for r in &recipients {
2617 let staff = *r == my_pk || roster.is_staff(&r.to_hex(), Some(&owner_hex));
2619 let ctl_root = if staff { community.control_root.as_ref() } else { None };
2620 blobs.push(
2621 super::rekey::build_base_blob(&signer, &my_pk.to_bytes(), r, Epoch(e), &new_root, &control_pk.to_bytes(), ctl_root)
2622 .await
2623 .map_err(|e| e.to_string())?,
2624 );
2625 }
2626 let severed_hop = existing
2630 .iter()
2631 .any(|c| c.scope == super::rekey::RekeyScope::Root && c.new_epoch == Epoch(e) && c.severed)
2632 || sever_marks(&cid_hex).contains_key(&e);
2633 let chunks = super::rekey::build_rekey_chunk_extension(
2634 &signer,
2635 my_pk,
2636 &group,
2637 super::rekey::RekeyScope::Root,
2638 Epoch(e),
2639 Epoch(e - 1),
2640 &prev_commit,
2641 &blobs,
2642 at_secs,
2643 citation.as_ref(),
2644 severed_hop,
2645 start_after,
2646 )
2647 .await
2648 .map_err(|e| e.to_string())?;
2649 for c in &chunks {
2650 transport.publish_durable(c, &community.relays).await?;
2651 report.chunks_published += 1;
2652 }
2653 report.hops += 1;
2654 }
2655
2656 let gb_group = super::derive::guestbook_group_key(&community.community_root, cid, community.root_epoch);
2661 let snap_id = crate::community::random_32();
2662 for rumor in guestbook::build_snapshot_rumors(my_pk, targets, snap_id, at_secs * 1000) {
2663 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
2664 let _ = transport.publish(&wrap, &community.relays).await;
2665 }
2666 }
2667
2668 crate::log_info!(
2669 "[Rescue] {} member(s): {} hop(s) re-vended ({} skipped), {} chunk(s) published, snapshot addendum at epoch {}",
2670 targets.len(),
2671 report.hops,
2672 report.hops_skipped,
2673 report.chunks_published,
2674 community.root_epoch.0
2675 );
2676 Ok(report)
2677 })
2678 .await
2679}
2680
2681pub async fn wire_guestbook_members<T: Transport + ?Sized>(
2687 transport: &T,
2688 community: &CommunityV2,
2689) -> Result<Vec<PublicKey>, String> {
2690 let (events, _newest, _reached_end) = fetch_guestbook_events(transport, community, 0).await?;
2691 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2692 let owner = community.owner()?;
2693 let owner_hex = owner.to_hex();
2694 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2695 let snapshot_authority_pk = snapshot_authority_for(&cid_hex, community, &owner);
2696 let snapshot_authority = snapshot_authority_pk.as_ref();
2697 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2698 let actor_hex = actor.to_hex();
2699 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2700 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2701 };
2702 let coalesced = guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick);
2703 let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2704 .unwrap_or_default()
2705 .iter()
2706 .filter_map(|h| PublicKey::from_hex(h).ok())
2707 .collect();
2708 Ok(coalesced
2709 .iter()
2710 .filter(|(pk, st)| st.verdict == guestbook::Verdict::Joined && !banlist.contains(pk))
2711 .map(|(pk, _)| *pk)
2712 .collect())
2713}
2714
2715pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
2725 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2726 let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
2727 return false;
2728 };
2729 let Ok(owner) = community.owner() else { return false };
2730 let owner_hex = owner.to_hex();
2731 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2732 let snapshot_authority_pk = snapshot_authority_for(&cid_hex, community, &owner);
2733 let snapshot_authority = snapshot_authority_pk.as_ref();
2734 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2735 let actor_hex = actor.to_hex();
2736 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2737 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2738 };
2739 matches!(
2740 guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
2741 Some(st) if st.verdict == guestbook::Verdict::Kicked
2742 )
2743}
2744
2745pub async fn sync_guestbook<T: Transport + ?Sized>(
2750 transport: &T,
2751 community: &CommunityV2,
2752) -> Result<Vec<guestbook::GuestbookEvent>, String> {
2753 crate::db::scoped(async move {
2754 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2755 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2756 let full = !guestbook_walked_this_session(&cid_hex);
2764 let since = if full { 0 } else { cursor.saturating_sub(1) };
2767 let (fresh, newest, reached_end) = fetch_guestbook_events(transport, community, since).await?;
2768 let live = Some(community.root_epoch.0);
2772 let mut by_id: std::collections::HashMap<[u8; 32], usize> = events.iter().enumerate().map(|(i, e)| (e.rumor_id, i)).collect();
2773 let mut added = Vec::new();
2774 let mut restamped = false;
2775 for mut ev in fresh {
2776 ev.epoch = live;
2777 match by_id.get(&ev.rumor_id) {
2778 Some(&i) => {
2779 if events[i].epoch.is_none() {
2780 events[i].epoch = live;
2781 restamped = true;
2782 }
2783 }
2784 None => {
2785 let mut ev = ev;
2788 if ev.observed_at.is_none() {
2789 ev.observed_at = Some(
2790 std::time::SystemTime::now()
2791 .duration_since(std::time::UNIX_EPOCH)
2792 .map(|d| d.as_secs())
2793 .unwrap_or(0),
2794 );
2795 }
2796 by_id.insert(ev.rumor_id, events.len());
2797 events.push(ev.clone());
2798 added.push(ev);
2799 }
2800 }
2801 }
2802 let advanced = if reached_end { newest.max(cursor) } else { cursor };
2806 if !added.is_empty() || advanced > cursor || restamped {
2807 crate::db::community::set_guestbook(&cid_hex, &events, advanced)?;
2808 mark_guestbook_observed_since(&cid_hex);
2809 }
2810 if full && reached_end {
2811 mark_guestbook_walked(&cid_hex);
2812 }
2813 Ok(added)
2814 })
2815 .await
2816}
2817
2818struct GuestbookWalked;
2822
2823fn guestbook_walked_set() -> std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>> {
2824 crate::db::current_session().scoped::<GuestbookWalked, _>()
2825}
2826
2827fn guestbook_walked_this_session(community_id: &str) -> bool {
2828 guestbook_walked_set().lock().is_ok_and(|s| s.contains(community_id))
2829}
2830
2831fn mark_guestbook_walked(community_id: &str) {
2832 if let Ok(mut s) = guestbook_walked_set().lock() {
2833 s.insert(community_id.to_string());
2834 }
2835}
2836
2837pub fn ingest_guestbook_event(community: &CommunityV2, mut ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
2840 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2841 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2842 if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
2843 return Ok(false);
2844 }
2845 ev.epoch = Some(community.root_epoch.0);
2846 events.push(ev);
2847 crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
2848 Ok(true)
2849}
2850
2851fn guestbook_observed_since(cid_hex: &str) -> Option<u64> {
2864 crate::db::settings::get_sql_setting(format!("gb_observed_since:{cid_hex}"))
2865 .ok()
2866 .flatten()
2867 .and_then(|v| v.parse::<u64>().ok())
2868}
2869
2870fn mark_guestbook_observed_since(cid_hex: &str) {
2871 let key = format!("gb_observed_since:{cid_hex}");
2872 if crate::db::settings::get_sql_setting(key.clone()).ok().flatten().is_some() {
2873 return; }
2875 let now = std::time::SystemTime::now()
2876 .duration_since(std::time::UNIX_EPOCH)
2877 .map(|d| d.as_secs())
2878 .unwrap_or(0);
2879 let _ = crate::db::settings::set_sql_setting(key, now.to_string());
2880}
2881
2882pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2883 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2884 let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2885 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2886 for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
2887 if let Ok(pk) = PublicKey::parse(&npub) {
2888 observed.insert(pk, last_active_secs.saturating_mul(1000));
2889 }
2890 }
2891 let roles = crate::db::community::get_community_roles(&cid_hex)?;
2892 let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2893 .unwrap_or_default()
2894 .iter()
2895 .filter_map(|h| PublicKey::from_hex(h).ok())
2896 .collect();
2897 let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
2900 .unwrap_or_default()
2901 .into_iter()
2902 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2903 .collect();
2904 fold_members(community, &events, observed, &roles, &banlist, &banned_at)
2905}
2906
2907pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2913 let (events, _newest, _reached_end) = fetch_guestbook_events(transport, community, 0).await?;
2914 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2917 for ch in &community.channels {
2918 if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
2919 for f in &page {
2920 let e = observed.entry(f.event.opened().author).or_insert(0);
2921 *e = (*e).max(f.event.opened().at_ms);
2922 }
2923 }
2924 }
2925
2926 let authority = fetch_authority(transport, community).await;
2931 let banlist: std::collections::BTreeSet<PublicKey> =
2933 authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
2934 let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
2938 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
2939 )
2940 .unwrap_or_default()
2941 .into_iter()
2942 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2943 .collect();
2944 for (h, at) in &authority.banned_at {
2945 if let Ok(pk) = PublicKey::from_hex(h) {
2946 let slot = banned_at.entry(pk).or_insert(0);
2947 *slot = (*slot).max(*at);
2948 }
2949 }
2950 fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
2951}
2952
2953pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2964 crate::db::scoped(async move {
2965 let signer = crate::signer::active_signer()?;
2966 let my_pk = me_pk()?;
2967 if community.owner()? != my_pk {
2968 return Err("only the owner can dissolve a community".to_string());
2969 }
2970 let at_ms = now_ms();
2971 let at = at_ms / 1000;
2972 let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
2973 let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
2974 transport.publish_durable(&wrap, &community.relays).await?;
2976 crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2977 if let Err(e) = tombstone_community_list(transport, community.id(), &community.relays, at_ms).await {
2986 crate::log_net_fail!("[CommunityList] dissolve tombstone failed ({}) — retrying in the background", e);
2987 tombstone_community_list_durable(*community.id(), community.relays.clone(), at_ms);
2988 }
2989 Ok(())
2990 })
2991 .await
2992}
2993
2994pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
2999 let group = super::derive::dissolved_group_key(community.id());
3000 let query = Query {
3001 kinds: vec![stream::KIND_WRAP],
3002 authors: vec![group.pk_hex()],
3003 limit: Some(20),
3004 ..Default::default()
3005 };
3006 let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
3007 return false;
3008 };
3009 wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
3010}
3011
3012pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
3025 refound_community_with(transport, community, removed, false, 0, None).await
3026}
3027
3028pub async fn refound_community_severing<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey], joined_after_cut: u64) -> Result<CommunityV2, String> {
3034 refound_community_with(transport, community, removed, true, joined_after_cut, None).await
3035}
3036
3037pub async fn refound_community_retaining<T: Transport + ?Sized>(
3051 transport: &T,
3052 community: &CommunityV2,
3053 removed: &[PublicKey],
3054 retain: &[PublicKey],
3055) -> Result<CommunityV2, String> {
3056 refound_community_with(transport, community, removed, false, 0, Some(retain)).await
3057}
3058
3059async fn refound_community_with<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey], sever: bool, joined_after_cut: u64, allow_only: Option<&[PublicKey]>) -> Result<CommunityV2, String> {
3060 crate::db::scoped(async move {
3061 let cid = community.id();
3062 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
3063 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
3065 return Err("this community has been dissolved; it cannot be re-founded".to_string());
3066 }
3067 let signer = crate::signer::active_signer()?;
3068 let my_pk = me_pk()?;
3069 let lock = super::realtime::follow_lock(cid);
3073 let _guard = lock.lock().await;
3074 let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
3078 let community = &fresh;
3079 let owner = community.owner()?;
3080
3081 {
3088 let owner_hex = owner.to_hex();
3089 let me_hex = my_pk.to_hex();
3090 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3096 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3097 let authorized = my_pk == owner
3098 || (!banned.contains(&me_hex)
3099 && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
3100 && removed.iter().all(|t| {
3101 roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
3102 }));
3103 if !authorized {
3104 return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
3105 }
3106 }
3107
3108 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
3112 .into_iter()
3113 .filter(|(_, f)| f.0 == community.root_epoch.0)
3114 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
3115 .collect();
3116 let current_control = control::ControlPlane::of(community);
3117 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
3124 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
3125 let mut oldest: Option<u64> = None;
3126 let mut until: Option<u64> = None;
3127 let mut truncated = false;
3131 for page in 0..COMPACT_MAX_PAGES {
3132 let query = Query {
3136 kinds: vec![stream::KIND_WRAP],
3137 authors: vec![current_control.pk_hex()],
3138 until,
3139 limit: Some(FOLLOW_PAGE),
3140 evidence: crate::community::transport::Evidence::Full,
3141 ..Default::default()
3142 };
3143 let wraps = transport.fetch(&query, &community.relays).await?;
3144 let mut fresh = 0usize;
3145 for w in &wraps {
3146 if !seen_wraps.insert(w.id) {
3147 continue;
3148 }
3149 fresh += 1;
3150 let at = w.created_at.as_secs();
3151 if oldest.is_none_or(|o| at < o) {
3152 oldest = Some(at);
3153 }
3154 if let Ok(parsed) = current_control.open(w) {
3155 opened.push(parsed);
3156 }
3157 }
3158 if fresh == 0 {
3159 truncated = wraps.len() >= FOLLOW_PAGE;
3163 break;
3164 }
3165 until = oldest;
3166 if page + 1 == COMPACT_MAX_PAGES {
3167 truncated = true;
3168 }
3169 }
3170 if truncated {
3171 return Err(
3172 "The community's control plane is too deep to read in full right now; re-founding stopped so no member is left behind.".to_string(),
3173 );
3174 }
3175
3176 let prev_epoch = community.root_epoch;
3177 let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
3178 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
3179 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
3184 let new_control_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::CONTROL_ROOT_SCOPE, new_epoch.0)?;
3189 let new_control_pk = super::derive::control_signer_group_key(&new_control_root, cid, new_epoch).pk();
3190 let new_control = control::split_write_group(&new_control_root, &new_root, cid, new_epoch);
3196 let at = now_ms();
3197 let at_secs = at / 1000;
3198
3199 use std::collections::BTreeMap;
3205 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
3206 for (i, (e, _)) in opened.iter().enumerate() {
3207 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
3208 }
3209 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
3210 for (floor_key, floor) in &floors {
3211 let head_idx = by_eid
3219 .get(floor_key)
3220 .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
3221 let Some(head_idx) = head_idx else {
3222 return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
3223 };
3224 let (head_ed, head_os) = &opened[head_idx];
3225 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
3226 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
3227 carried.push((h, rewrapped));
3228 }
3229
3230 let members = memberlist(transport, community).await?;
3239 let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
3240 let net_len = members.len();
3241 let mut union: std::collections::BTreeSet<PublicKey> = members.into_iter().collect();
3242 let local = stored_memberlist(community)?;
3243 let local_len = local.len();
3244 union.extend(local);
3245 let mut recipients: Vec<PublicKey> = union.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
3246 if !recipients.iter().any(|p| *p == my_pk) {
3247 recipients.push(my_pk);
3248 }
3249
3250 let mut roster_for_channels = fetch_authority(transport, community).await.roles;
3258 {
3259 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3260 for r in cached.roles {
3261 if !roster_for_channels.roles.iter().any(|x| x.role_id == r.role_id) {
3262 roster_for_channels.roles.push(r);
3263 }
3264 }
3265 for g in cached.grants {
3266 if !roster_for_channels.grants.iter().any(|x| x.member == g.member) {
3267 roster_for_channels.grants.push(g);
3268 }
3269 }
3270 }
3271 let owner_hex_for_channels = community.owner().ok().map(|o| o.to_hex());
3272
3273 if let Some(allow) = allow_only {
3281 let allowed: std::collections::HashSet<[u8; 32]> = allow.iter().map(|p| p.to_bytes()).collect();
3282 let before = recipients.len();
3283 recipients.retain(|r| {
3284 *r == my_pk
3285 || Some(r.to_hex()) == owner_hex_for_channels
3286 || allowed.contains(&r.to_bytes())
3287 });
3288 let dropped = before.saturating_sub(recipients.len());
3289 if dropped > 0 {
3290 crate::log_info!(
3291 "[Refound {}] purge: {} member(s) not on the retain list vended no key",
3292 &cid_hex[..8.min(cid_hex.len())], dropped
3293 );
3294 }
3295 }
3296
3297 if joined_after_cut > 0 {
3310 match fetch_guestbook_events(transport, community, 0).await {
3311 Ok((events, _, _)) => {
3312 let mut first_seen: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
3313 for ev in &events {
3314 if let guestbook::GuestbookEntry::Join { member, at_ms, .. } = &ev.entry {
3315 let secs = at_ms / 1000;
3316 first_seen.entry(*member).and_modify(|e| *e = (*e).min(secs)).or_insert(secs);
3317 }
3318 }
3319 if guestbook_observed_since(&cid_hex).is_some_and(|since| since < joined_after_cut) {
3326 if let Ok((local, _)) = crate::db::community::get_guestbook(&cid_hex) {
3327 for ev in &local {
3328 let (guestbook::GuestbookEntry::Join { member, .. }, Some(seen)) = (&ev.entry, ev.observed_at) else {
3329 continue;
3330 };
3331 first_seen.entry(*member).and_modify(|e| *e = (*e).max(seen)).or_insert(seen);
3334 }
3335 }
3336 }
3337 let before = recipients.len();
3338 recipients.retain(|r| {
3339 if *r == my_pk || Some(r.to_hex()) == owner_hex_for_channels {
3340 return true;
3341 }
3342 if roster_for_channels.is_staff(&r.to_hex(), owner_hex_for_channels.as_deref()) {
3344 return true;
3345 }
3346 first_seen.get(r).is_none_or(|joined| *joined < joined_after_cut)
3347 });
3348 let dropped = before.saturating_sub(recipients.len());
3349 if dropped > 0 {
3350 crate::log_info!(
3351 "[Refound {}] containment: {} raid-window joiner(s) vended no key",
3352 &cid_hex[..8.min(cid_hex.len())], dropped
3353 );
3354 }
3355 }
3356 Err(e) => crate::log_warn!(
3357 "[Refound {}] guestbook unreadable — raid-window joiners NOT filtered, only the named cut is severed ({e})",
3358 &cid_hex[..8.min(cid_hex.len())]
3359 ),
3360 }
3361 }
3362
3363 {
3380 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3382 let missing: Vec<String> = roster_for_channels
3383 .grants
3384 .iter()
3385 .filter(|g| !g.role_ids.is_empty())
3386 .filter_map(|g| PublicKey::from_hex(&g.member).ok())
3387 .filter(|pk| !removed_set.contains(&pk.to_bytes()))
3388 .filter(|pk| !banned.contains(&pk.to_hex()))
3389 .filter(|pk| !recipients.contains(pk))
3390 .map(|pk| pk.to_hex())
3391 .collect();
3392 if !missing.is_empty() {
3393 return Err(format!(
3397 "re-founding aborted: {} granted member(s) would be vended no key and stranded at epoch {} — {}; \
3398 recipients={} (network fold {}, local fold {}); no state published",
3399 missing.len(),
3400 prev_epoch.0,
3401 missing.iter().map(|h| h[..8.min(h.len())].to_string()).collect::<Vec<_>>().join(", "),
3402 recipients.len(),
3403 net_len,
3404 local_len
3405 ));
3406 }
3407 }
3408
3409 let mut base_blobs = Vec::new();
3417 for r in &recipients {
3418 let staff = *r == my_pk || roster_for_channels.is_staff(&r.to_hex(), owner_hex_for_channels.as_deref());
3419 base_blobs.push(
3420 super::rekey::build_base_blob(&signer, &my_pk.to_bytes(), r, new_epoch, &new_root, &new_control_pk.to_bytes(), staff.then_some(&new_control_root))
3421 .await
3422 .map_err(|e| e.to_string())?,
3423 );
3424 }
3425 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
3426 let base_chunks =
3427 super::rekey::build_rekey_chunks(&signer, my_pk, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), sever)
3428 .await
3429 .map_err(|e| e.to_string())?;
3430
3431 let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
3438 let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
3439 for ch in &community.channels {
3440 let (Some(old_key), true) = (ch.key, ch.private) else { continue };
3441 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
3442 let entitled: Vec<PublicKey> = recipients
3443 .iter()
3444 .copied()
3445 .filter(|r| {
3446 *r == my_pk
3447 || roster_for_channels.is_entitled(owner_hex_for_channels.as_deref(), &r.to_hex(), &ch_hex, &[], &[])
3448 })
3449 .collect();
3450 let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
3451 let ch_new_key = mint_or_reuse_rotation_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch_new_epoch.0)?;
3455 let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
3456 let mut ch_blobs = Vec::new();
3457 for r in &entitled {
3458 ch_blobs.push(
3459 super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
3460 .await
3461 .map_err(|e| e.to_string())?,
3462 );
3463 }
3464 let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
3465 let ch_chunks = super::rekey::build_rekey_chunks(&signer, my_pk, &ch_group, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, ch.epoch, &ch_prev_commit, &ch_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
3466 .await
3467 .map_err(|e| e.to_string())?;
3468 channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
3469 channel_chunk_sets.push(ch_chunks);
3470 }
3471
3472 for c in &base_chunks {
3475 transport.publish_durable(c, &community.relays).await?;
3476 }
3477 for set in &channel_chunk_sets {
3478 for c in set {
3479 transport.publish_durable(c, &community.relays).await?;
3480 }
3481 }
3482 for (_, wrap) in &carried {
3483 transport.publish_durable(wrap, &community.relays).await?;
3484 }
3485 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
3488 let snap_id = crate::community::random_32();
3489 for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
3490 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
3491 let _ = transport.publish(&wrap, &community.relays).await;
3492 }
3493 }
3494
3495 if crate::db::community::community_protocol(cid)?.is_none() {
3497 return Ok(community.clone()); }
3499 let mut updated = community.clone();
3503 updated.community_root = new_root;
3504 updated.root_epoch = new_epoch;
3505 updated.control_pk = Some(new_control_pk);
3506 updated.control_root = Some(new_control_root);
3507 for (id, key, ep) in &channel_updates {
3508 if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
3509 c.key = Some(*key);
3510 c.epoch = *ep;
3511 }
3512 }
3513 crate::db::community::save_community_v2(&updated)?;
3514 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
3519 for (h, _) in &carried {
3520 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
3521 }
3522 if let Some(client) = crate::state::nostr_client() {
3528 super::realtime::refresh_subscription(&client).await;
3529 }
3530 record_epoch_minter(&cid_hex, new_epoch.0, &my_pk.to_hex());
3534 if sever {
3535 add_sever_mark(&cid_hex, new_epoch.0, at_secs);
3539 for attempt in 0..3u8 {
3540 match sever_own_links(transport, &updated).await {
3541 Ok(_) => break,
3542 Err(e) if attempt == 2 => {
3543 crate::log_warn!("v2: post-severing link revocation failed after retries ({e}); the sever mark retries it on the next follow");
3544 }
3545 Err(_) => continue,
3546 }
3547 }
3548 } else {
3549 for attempt in 0..3u8 {
3555 match refresh_public_links(transport, &updated).await {
3556 Ok(()) => break,
3557 Err(e) if attempt == 2 => {
3558 crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
3559 }
3560 Err(_) => continue,
3561 }
3562 }
3563 }
3564 Ok(updated)
3565 })
3566 .await
3567}
3568
3569pub async fn refound_at_birth<T: Transport + ?Sized>(
3586 transport: &T,
3587 community: &CommunityV2,
3588 snapshot_members: &[PublicKey],
3589) -> Result<CommunityV2, String> {
3590 crate::db::scoped(async move {
3591 let cid = community.id();
3592 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
3593 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
3596 return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
3597 }
3598 let signer = crate::signer::active_signer()?;
3599 let my_pk = me_pk()?;
3600 if my_pk != community.owner()? {
3601 return Err("only the owner can birth-refound the migration twin".to_string());
3602 }
3603 let lock = super::realtime::follow_lock(cid);
3604 let _guard = lock.lock().await;
3605 let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
3606 if community.root_epoch.0 == 1 {
3612 return Ok(community);
3613 }
3614 if community.root_epoch.0 != 0 {
3615 return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
3616 }
3617 let community = &community;
3618
3619 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
3623 .into_iter()
3624 .filter(|(_, f)| f.0 == community.root_epoch.0)
3625 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
3626 .collect();
3627 let current_control = control::ControlPlane::of(community);
3628 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
3629 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
3630 let mut oldest: Option<u64> = None;
3631 let mut until: Option<u64> = None;
3632 let mut truncated = false;
3634 for page in 0..COMPACT_MAX_PAGES {
3635 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![current_control.pk_hex()], until, limit: Some(FOLLOW_PAGE), evidence: crate::community::transport::Evidence::Full, ..Default::default() };
3636 let wraps = transport.fetch(&query, &community.relays).await?;
3637 let mut fresh = 0usize;
3638 for w in &wraps {
3639 if !seen_wraps.insert(w.id) { continue; }
3640 fresh += 1;
3641 let at = w.created_at.as_secs();
3642 if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
3643 if let Ok(parsed) = current_control.open(w) {
3644 opened.push(parsed);
3645 }
3646 }
3647 if fresh == 0 {
3648 truncated = wraps.len() >= FOLLOW_PAGE;
3649 break;
3650 }
3651 until = oldest;
3652 if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
3653 }
3654 if truncated {
3655 return Err(
3656 "The community's control plane is too deep to read in full right now; re-founding stopped so no member is left behind.".to_string(),
3657 );
3658 }
3659
3660 let prev_epoch = community.root_epoch; let new_epoch = Epoch(1);
3662 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
3663 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
3664 let new_control_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::CONTROL_ROOT_SCOPE, new_epoch.0)?;
3666 let new_control_pk = super::derive::control_signer_group_key(&new_control_root, cid, new_epoch).pk();
3667 let new_control = control::split_write_group(&new_control_root, &new_root, cid, new_epoch);
3668 let at = now_ms();
3669 let at_secs = at / 1000;
3670
3671 use std::collections::BTreeMap;
3672 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
3673 for (i, (e, _)) in opened.iter().enumerate() {
3674 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
3675 }
3676 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
3677 for (floor_key, floor) in &floors {
3678 let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
3679 let Some(head_idx) = head_idx else {
3680 return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
3681 };
3682 let (head_ed, head_os) = &opened[head_idx];
3683 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
3684 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
3685 carried.push((h, rewrapped));
3686 }
3687
3688 let base_blobs = vec![
3691 super::rekey::build_base_blob(&signer, &my_pk.to_bytes(), &my_pk, new_epoch, &new_root, &new_control_pk.to_bytes(), Some(&new_control_root))
3692 .await
3693 .map_err(|e| e.to_string())?,
3694 ];
3695 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
3696 let base_chunks =
3697 super::rekey::build_rekey_chunks(&signer, my_pk, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
3698 .await
3699 .map_err(|e| e.to_string())?;
3700
3701 for c in &base_chunks {
3703 transport.publish_durable(c, &community.relays).await?;
3704 }
3705 for (_, wrap) in &carried {
3706 transport.publish_durable(wrap, &community.relays).await?;
3707 }
3708 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
3716 let snap_id = crate::community::random_32();
3717 let snapshot_wraps: Vec<Event> = {
3718 let mut out = Vec::new();
3719 for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
3720 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
3721 .await
3722 .map_err(|e| format!("seal birth snapshot: {e}"))?;
3723 out.push(wrap);
3724 }
3725 out
3726 };
3727 for wrap in &snapshot_wraps {
3728 transport.publish_durable(wrap, &community.relays).await?;
3729 }
3730 let verify_view = {
3739 let mut v = community.clone();
3740 v.community_root = new_root;
3741 v.root_epoch = new_epoch;
3742 v.control_pk = Some(new_control_pk);
3743 v.control_root = Some(new_control_root);
3744 v
3745 };
3746 let expected: Vec<PublicKey> = {
3747 let banlist = fetch_authority(transport, &verify_view).await.banned;
3748 snapshot_members.iter().copied()
3749 .filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
3750 .collect()
3751 };
3752 if !expected.is_empty() {
3753 let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
3754 let missing = expected.iter().filter(|m| !folded.contains(m)).count();
3755 if missing > 0 {
3756 return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
3757 }
3758 }
3759
3760 if crate::db::community::community_protocol(cid)?.is_none() {
3762 return Ok(community.clone());
3763 }
3764 let mut updated = community.clone();
3765 updated.community_root = new_root;
3766 updated.root_epoch = new_epoch;
3767 updated.control_pk = Some(new_control_pk);
3768 updated.control_root = Some(new_control_root);
3769 crate::db::community::save_community_v2(&updated)?;
3770 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
3771 for (h, _) in &carried {
3772 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
3773 }
3774 Ok(updated)
3775 })
3776 .await
3777}
3778
3779fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
3786 if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
3787 return Ok(existing);
3788 }
3789 let fresh = crate::community::random_32();
3790 crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
3791 Ok(fresh)
3792}
3793
3794fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
3800 let hex = crate::simd::hex::bytes_to_hex_32;
3801 let channels = community
3802 .channels
3803 .iter()
3804 .filter(|c| c.private)
3805 .filter_map(|c| {
3809 c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: Some(hex(&k)), epoch: c.epoch.0, name: c.name.clone(), extra: Default::default() })
3810 })
3811 .collect();
3812 super::list::JoinMaterial {
3813 community_id: hex(&community.identity.community_id.0),
3814 owner: hex(&community.identity.owner_xonly),
3815 owner_salt: hex(&community.identity.owner_salt),
3816 community_root: hex(&community.community_root),
3817 root_epoch: community.root_epoch.0,
3818 control_pk: community.control_pk.map(|p| p.to_hex()),
3819 control_root: community.control_root.map(|r| hex(&r)),
3823 channels,
3824 relays: community.relays.clone(),
3825 name: community.name.clone(),
3826 extra: Default::default(),
3827 }
3828}
3829
3830fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
3834 let channels = jm
3837 .channels
3838 .iter()
3839 .filter_map(|c| {
3840 c.key.as_ref().map(|k| invite::ChannelGrant { id: c.id.clone(), key: k.clone(), epoch: c.epoch, name: c.name.clone() })
3841 })
3842 .collect();
3843 CommunityInvite {
3844 community_id: jm.community_id.clone(),
3845 owner: jm.owner.clone(),
3846 owner_salt: jm.owner_salt.clone(),
3847 community_root: jm.community_root.clone(),
3848 root_epoch: jm.root_epoch,
3849 control_pk: jm.control_pk.clone(),
3850 channels,
3851 relays: jm.relays.clone(),
3852 name: jm.name.clone(),
3853 icon: None,
3854 expires_at: None,
3855 creator_npub: None,
3856 label: None,
3857 extra: Default::default(),
3858 }
3859}
3860
3861async fn own_list_relays() -> Vec<String> {
3866 let Some(client) = crate::state::nostr_client() else { return Vec::new() };
3867 client
3868 .relays()
3869 .await
3870 .iter()
3871 .filter(|(_, r)| r.capabilities().load().can_write())
3872 .map(|(url, _)| url.to_string())
3873 .collect()
3874}
3875
3876async fn list_read_relays(extra: &[String]) -> Vec<String> {
3881 let mut set = own_list_relays().await;
3882 set.extend(invite::stock_relays());
3883 set.extend(held_v2_relays());
3884 set.extend(extra.iter().cloned());
3885 set.sort();
3886 set.dedup();
3887 set
3888}
3889
3890pub struct FragSet {
3893 pub list: super::list::CommunityList,
3894 pub created_at: std::collections::BTreeMap<usize, u64>,
3895 pub declared: usize,
3897 pub read_frags: std::collections::BTreeMap<usize, super::list_frag::FragList>,
3900}
3901
3902impl FragSet {
3903 pub fn is_complete(&self) -> bool {
3906 (0..self.declared).all(|i| self.created_at.contains_key(&i))
3907 }
3908}
3909
3910async fn fetch_fragments<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<FragSet>, String> {
3913 let signer = crate::signer::active_signer()?;
3914 let my_pk = me_pk()?;
3915 let query = Query {
3916 kinds: vec![super::kind::COMMUNITY_LIST_FRAG],
3917 authors: vec![my_pk.to_hex()],
3918 limit: Some(64),
3919 ..Default::default()
3920 };
3921 let events = transport.fetch(&query, relays).await?;
3922 if events.is_empty() {
3923 return Ok(None);
3924 }
3925 let mut newest: std::collections::BTreeMap<usize, (u64, [u8; 32], super::list_frag::FragList)> = Default::default();
3926 let mut undecryptable = 0usize;
3927 for e in events {
3928 let at = e.created_at.as_secs();
3929 let id = *e.id.as_bytes();
3930 match super::list_frag::parse_fragment_event(&signer, my_pk, &e).await {
3931 Ok((index, frag)) => {
3932 let wins = match newest.get(&index) {
3935 None => true,
3936 Some((prev_at, prev_id, _)) => at > *prev_at || (at == *prev_at && id < *prev_id),
3937 };
3938 if wins {
3939 newest.insert(index, (at, id, frag));
3940 }
3941 }
3942 Err(_) => undecryptable += 1,
3943 }
3944 }
3945 if newest.is_empty() {
3946 crate::log_warn!("[CommunityList] {} fragment(s) fetched, none readable — treating as no news", undecryptable);
3947 return Ok(None);
3948 }
3949 let declared = newest
3953 .values()
3954 .max_by_key(|(at, _, f)| (*at, f.frags))
3955 .map(|(_, _, f)| f.frags)
3956 .unwrap_or(1)
3957 .max(1);
3958 let read_frags: std::collections::BTreeMap<usize, super::list_frag::FragList> =
3959 newest.iter().map(|(i, (_, _, f))| (*i, f.clone())).collect();
3960 let frags: Vec<_> = read_frags.values().cloned().collect();
3961 let set = FragSet {
3962 list: super::list_frag::defragment(&frags),
3963 created_at: newest.iter().map(|(i, (at, _, _))| (*i, *at)).collect(),
3964 declared,
3965 read_frags,
3966 };
3967 if !set.is_complete() {
3968 crate::log_warn!(
3969 "[CommunityList] INCOMPLETE: hold {} of {} fragment(s) — reading, refusing to write",
3970 set.created_at.len(),
3971 declared
3972 );
3973 }
3974 Ok(Some(set))
3975}
3976
3977async fn publish_fragments<T: Transport + ?Sized>(
3982 transport: &T,
3983 list: &super::list::CommunityList,
3984 prev: &std::collections::BTreeMap<usize, u64>,
3985 read: &std::collections::BTreeMap<usize, super::list_frag::FragList>,
3986 own: &[String],
3987) -> Result<usize, String> {
3988 let signer = crate::signer::active_signer()?;
3989 let my_pk = me_pk()?;
3990 let frags = super::list_frag::fragment(list);
3991 let now = now_ms() / 1000;
3992 let unchanged = |index: usize, frag: &super::list_frag::FragList| -> bool {
3996 let Some(old) = read.get(&index) else { return false };
3997 matches!(
3998 (serde_json::to_string(old), serde_json::to_string(frag)),
3999 (Ok(a), Ok(b)) if a == b
4000 )
4001 };
4002 crate::log_net_info!(
4005 "[CommunityList] publishing {} fragment(s): {} live entries ({} retired), {} tombstones",
4006 frags.len(),
4007 frags.iter().map(|f| f.entries.len()).sum::<usize>(),
4008 list.entries.len() - frags.iter().map(|f| f.entries.len()).sum::<usize>(),
4009 frags.iter().map(|f| f.tombstones.len()).sum::<usize>(),
4010 );
4011 let mut skipped = 0usize;
4012 for (index, frag) in frags.iter().enumerate() {
4013 if unchanged(index, frag) {
4014 skipped += 1;
4015 continue;
4016 }
4017 let created_at = now.max(prev.get(&index).copied().unwrap_or(0) + 1);
4018 let event = super::list_frag::build_fragment_event(&signer, my_pk, frag, index, created_at).await?;
4019 publish_list_event(transport, &event, own).await?;
4020 }
4021 for index in frags.len()..prev.len() {
4024 let empty = super::list_frag::FragList {
4025 frags: frags.len(),
4026 entries: vec![],
4027 tombstones: vec![],
4028 extra: Default::default(),
4029 };
4030 if unchanged(index, &empty) {
4031 skipped += 1;
4032 continue;
4033 }
4034 let created_at = now.max(prev.get(&index).copied().unwrap_or(0) + 1);
4035 let event = super::list_frag::build_fragment_event(&signer, my_pk, &empty, index, created_at).await?;
4036 publish_list_event(transport, &event, own).await?;
4037 }
4038 if skipped > 0 {
4039 crate::log_net_info!("[CommunityList] {} fragment(s) skipped — byte-identical to the relay copy", skipped);
4040 }
4041 Ok(frags.len())
4042}
4043
4044async fn publish_list_event<T: Transport + ?Sized>(transport: &T, event: &Event, own: &[String]) -> Result<(), String> {
4048 if !own.is_empty() {
4049 match transport.publish(event, own).await {
4050 Ok(()) => return Ok(()),
4051 Err(e) => crate::log_warn!("[CommunityList] own relays refused the list ({}) — falling back to the stock set", e),
4052 }
4053 }
4054 transport.publish(event, &invite::stock_relays()).await
4055}
4056
4057fn held_v2_relays() -> Vec<String> {
4060 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4061 if let Ok(ids) = crate::db::community::list_community_ids() {
4062 for id in ids {
4063 if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
4064 if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
4065 set.extend(c.relays);
4066 }
4067 }
4068 }
4069 }
4070 set.into_iter().collect()
4071}
4072
4073pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
4085 crate::db::scoped(async move {
4086 let held = held_v2_relays();
4087 if held.is_empty() {
4088 return Ok(false); }
4090 let relays = list_read_relays(&[]).await;
4091 let own = own_list_relays().await;
4092 let write_relays = if own.is_empty() { held.clone() } else { own };
4096 let (remote, prev_created, read_frags) = match fetch_fragments(transport, &relays).await {
4100 Ok(None) => (super::list::CommunityList::default(), Default::default(), Default::default()),
4103 Ok(Some(set)) if !set.is_complete() => {
4104 crate::log_net_fail!(
4105 "[CommunityList] republish SKIPPED — hold {} of {} fragments; rewriting a set we haven't fully read would drop the memberships in the ones we're missing",
4106 set.created_at.len(),
4107 set.declared
4108 );
4109 return Ok(false);
4110 }
4111 Ok(Some(set)) => (set.list, set.created_at, set.read_frags),
4112 Err(e) => {
4113 crate::log_warn!(
4119 "[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
4120 e,
4121 just_joined
4122 .map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
4123 .unwrap_or_default()
4124 );
4125 return Ok(false);
4126 }
4127 };
4128 let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
4129 let now = now_ms();
4130 let mut local = super::list::CommunityList::default();
4131 for id in crate::db::community::list_community_ids()? {
4132 if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
4133 continue;
4134 }
4135 let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
4136 let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
4137 let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
4138 let tombstoned_at = remote
4150 .tombstones
4151 .iter()
4152 .find(|t| t.community_id == cid_hex)
4153 .map(|t| t.removed_at)
4154 .unwrap_or(0);
4155 let held_since = c.created_at_ms;
4156 if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
4157 crate::log_warn!(
4158 "[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
4159 &cid_hex[..8], tombstoned_at, held_since
4160 );
4161 continue;
4162 }
4163 let added_at = if remote.is_live(&cid_hex) && !is_join {
4168 remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
4169 } else if !is_join && tombstoned_at > 0 {
4170 held_since
4171 } else {
4172 now
4173 };
4174 let jm = join_material(&c);
4175 local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
4176 }
4177 let merged = remote.merge(&local);
4178 match publish_fragments(transport, &merged, &prev_created, &read_frags, &write_relays).await {
4181 Ok(_) => {}
4182 Err(e) => {
4183 crate::log_net_fail!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
4184 return Err(e);
4185 }
4186 }
4187 Ok(true)
4188 })
4189 .await
4190}
4191
4192const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
4197
4198pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
4211 if crate::state::nostr_client().is_none() {
4212 return;
4213 }
4214 crate::db::spawn_bound(async move {
4215 for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
4216 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4217 match republish_community_list(&transport, just_joined.as_ref()).await {
4218 Ok(true) => {
4219 if attempt > 0 {
4220 crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
4221 }
4222 return;
4223 }
4224 Ok(false) => {} Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
4226 }
4227 tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
4228 }
4229 crate::log_warn!(
4230 "[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
4231 LIST_REPUBLISH_BACKOFF_SECS.len()
4232 );
4233 });
4234}
4235
4236async fn tombstone_community_list<T: Transport + ?Sized>(
4241 transport: &T,
4242 community_id: &crate::community::CommunityId,
4243 relays: &[String],
4244 removed_at: u64,
4245) -> Result<(), String> {
4246 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
4247 let read_relays = list_read_relays(relays).await;
4248 let own = own_list_relays().await;
4249 let write_relays = if own.is_empty() { relays.to_vec() } else { own };
4250 let (mut doc, prev_created, read_frags) = match fetch_fragments(transport, &read_relays).await {
4254 Ok(Some(set)) if !set.is_complete() => {
4255 return Err(format!("hold {} of {} fragments — refusing to rewrite a set we haven't fully read", set.created_at.len(), set.declared));
4256 }
4257 Ok(Some(set)) => (set.list, set.created_at, set.read_frags),
4258 Ok(None) => (super::list::CommunityList::default(), Default::default(), Default::default()),
4259 Err(e) => return Err(e),
4260 };
4261 doc.tombstones.retain(|t| t.community_id != cid_hex);
4262 doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at, extra: Default::default() });
4263 publish_fragments(transport, &doc, &prev_created, &read_frags, &write_relays).await.map(|_| ())
4267}
4268
4269pub fn tombstone_community_list_durable(community_id: crate::community::CommunityId, relays: Vec<String>, removed_at: u64) {
4277 if crate::state::nostr_client().is_none() {
4278 return;
4279 }
4280 crate::db::spawn_bound(async move {
4281 for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
4282 tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
4283 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4284 match tombstone_community_list(&transport, &community_id, &relays, removed_at).await {
4285 Ok(()) => {
4286 crate::log_net_info!(
4287 "[CommunityList] leave tombstone for {} landed on retry #{}",
4288 &crate::simd::hex::bytes_to_hex_32(&community_id.0)[..8],
4289 attempt + 1
4290 );
4291 return;
4292 }
4293 Err(e) => crate::log_warn!("[CommunityList] leave tombstone retry #{} failed: {}", attempt + 1, e),
4294 }
4295 }
4296 crate::log_net_fail!(
4297 "[CommunityList] leave tombstone for {} NEVER landed after {} attempts — it will come back on another device",
4298 &crate::simd::hex::bytes_to_hex_32(&community_id.0)[..8],
4299 LIST_REPUBLISH_BACKOFF_SECS.len()
4300 );
4301 });
4302}
4303
4304pub struct ListSyncOutcome {
4311 pub joined: Vec<CommunityV2>,
4313 pub removed: Vec<(String, Vec<String>)>,
4320}
4321
4322const LIST_SEEDED_KEY: &str = "community_list_frag_seeded";
4324
4325pub const ERR_DISSOLVED: &str = "this community has been dissolved";
4330
4331pub async fn retire_dead_invite<T: Transport + ?Sized>(transport: &T, community_id_hex: &str, relays: &[String]) {
4335 let _ = crate::db::community::delete_pending_invite(community_id_hex);
4336 if let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(community_id_hex) {
4337 let id = crate::community::CommunityId(cid);
4338 let at = now_ms();
4339 if tombstone_community_list(transport, &id, &relays.to_vec(), at).await.is_err() {
4340 tombstone_community_list_durable(id, relays.to_vec(), at);
4343 }
4344 }
4345 crate::emit_event("community_invites_purged", &serde_json::json!({}));
4346}
4347
4348async fn confirmed_no_fragments<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> bool {
4353 let Ok(my_pk) = me_pk() else { return false };
4354 if relays.is_empty() {
4355 return false;
4356 }
4357 let query = Query {
4358 kinds: vec![super::kind::COMMUNITY_LIST_FRAG],
4359 authors: vec![my_pk.to_hex()],
4360 limit: Some(1),
4361 ..Default::default()
4362 };
4363 for relay in relays {
4364 match transport.fetch(&query, std::slice::from_ref(relay)).await {
4365 Ok(events) if events.is_empty() => {}
4366 _ => return false,
4369 }
4370 }
4371 true
4372}
4373
4374async fn seed_community_list<T: Transport + ?Sized>(transport: &T) {
4380 if crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).ok().flatten().is_some() {
4381 return;
4382 }
4383 if held_v2_relays().is_empty() {
4384 return;
4385 }
4386 let own = own_list_relays().await;
4389 let confirm = if own.is_empty() { held_v2_relays() } else { own };
4390 if !confirmed_no_fragments(transport, &confirm).await {
4391 crate::log_warn!("[CommunityList] seed DEFERRED — the empty read is unconfirmed; retrying on a later boot");
4392 return;
4393 }
4394 crate::log_net_info!("[CommunityList] no fragments held anywhere (confirmed per relay) — seeding from local state");
4395 match republish_community_list(transport, None).await {
4396 Ok(true) => {
4397 let _ = crate::db::settings::set_sql_setting(LIST_SEEDED_KEY.to_string(), "1".to_string());
4398 }
4399 Ok(false) => {}
4400 Err(e) => crate::log_net_fail!("[CommunityList] seed failed: {e}"),
4401 }
4402}
4403
4404pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
4405 crate::db::scoped(async move {
4406 let relays = list_read_relays(bootstrap_relays).await;
4407 if relays.is_empty() {
4408 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
4409 }
4410 let list = match fetch_fragments(transport, &relays).await {
4414 Ok(Some(set)) => {
4415 crate::log_net_info!(
4416 "[CommunityList] fetched: {} of {} fragment(s), {} entries, {} tombstones, across {} relays",
4417 set.created_at.len(),
4418 set.declared,
4419 set.list.entries.len(),
4420 set.list.tombstones.len(),
4421 relays.len()
4422 );
4423 set.list
4424 }
4425 Ok(None) => {
4426 crate::log_debug!("[CommunityList] no fragments across {} relays", relays.len());
4430 seed_community_list(transport).await;
4431 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
4432 }
4433 Err(e) => {
4434 crate::log_net_fail!("[CommunityList] fetch failed across {} relays: {e}", relays.len());
4435 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
4436 }
4437 };
4438 let mut removed: Vec<(String, Vec<String>)> = Vec::new();
4444 for t in &list.tombstones {
4445 if list.is_live(&t.community_id) {
4446 continue;
4447 }
4448 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
4449 let id = crate::community::CommunityId(cid);
4450 let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
4451 continue; };
4453 if held.created_at_ms > t.removed_at {
4459 crate::log_warn!(
4460 "[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
4461 &t.community_id[..8], t.removed_at, held.created_at_ms
4462 );
4463 continue;
4464 }
4465 let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
4466 let _ = crate::db::community::delete_community(&t.community_id);
4467 removed.push((t.community_id.clone(), channel_ids));
4468 }
4469 let mut invites_purged = 0usize;
4475 for t in &list.tombstones {
4476 if list.is_live(&t.community_id) {
4477 continue;
4478 }
4479 let Ok(Some(received_at)) = crate::db::community::pending_invite_received_at(&t.community_id) else { continue };
4480 if (received_at.max(0) as u64).saturating_mul(1000) <= t.removed_at
4481 && crate::db::community::delete_pending_invite(&t.community_id).is_ok()
4482 {
4483 invites_purged += 1;
4484 }
4485 }
4486 if invites_purged > 0 {
4487 crate::emit_event("community_invites_purged", &serde_json::json!({}));
4488 }
4489 let mut joined = Vec::new();
4490 for entry in list.live_entries() {
4491 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
4492 if crate::db::community::community_exists(&crate::community::CommunityId(cid)).unwrap_or(false) {
4497 continue; }
4499 let bundle = material_to_invite(&entry.current);
4504 match accept_bundle(transport, &bundle, None, false).await {
4505 Ok(community) => {
4506 if let (Some(pk), Some(root_hex)) = (community.control_pk, entry.current.control_root.as_deref()) {
4512 if let Some(root) = crate::simd::hex::hex_to_bytes_32_checked(root_hex) {
4513 if super::derive::control_signer_group_key(&root, community.id(), community.root_epoch).pk() == pk
4514
4515 {
4516 let mut held = community.clone();
4517 held.control_root = Some(root);
4518 let _ = crate::db::community::save_community_v2(&held);
4519 }
4520 }
4521 }
4522 joined.push(community);
4523 }
4524 Err(e) => crate::log_net_fail!(
4528 "[CommunityList] {} is listed but adoption failed: {e}",
4529 &entry.community_id[..entry.community_id.len().min(8)]
4530 ),
4531 }
4532 }
4533 Ok(ListSyncOutcome { joined, removed })
4534 })
4535 .await
4536}
4537
4538pub(super) fn citation_is_synced(
4568 cid_hex: &str,
4569 owner_hex: &str,
4570 actor_hex: &str,
4571 citation: Option<&crate::community::edition::AuthorityCitation>,
4572) -> bool {
4573 if owner_hex == actor_hex {
4574 return true;
4575 }
4576 if citation.is_none() {
4577 return false;
4578 }
4579 let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
4580 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
4581 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
4582 &crate::community::CommunityId(cid_bytes),
4583 &actor_bytes,
4584 ));
4585 let head: Vec<crate::community::roster::EntityHead> =
4586 crate::db::community::get_edition_head(cid_hex, &grant_hex)
4587 .ok()
4588 .flatten()
4589 .map(|(version, self_hash)| crate::community::roster::EntityHead {
4590 entity_hex: grant_hex.clone(),
4591 version,
4592 self_hash,
4593 inner_id: [0u8; 32],
4594 citation: None,
4595 })
4596 .into_iter()
4597 .collect();
4598 crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
4599}
4600
4601fn required_authority_citation(
4609 community: &CommunityV2,
4610 actor: &PublicKey,
4611) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
4612 if community.owner().ok().as_ref() == Some(actor) {
4613 return Ok(None); }
4615 my_authority_citation(community, actor).map(Some).ok_or_else(|| {
4616 "your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
4617 })
4618}
4619
4620fn my_authority_citation(
4621 community: &CommunityV2,
4622 actor: &PublicKey,
4623) -> Option<crate::community::edition::AuthorityCitation> {
4624 if community.owner().ok().as_ref() == Some(actor) {
4625 return None;
4626 }
4627 let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
4628 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4629 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
4630 crate::db::community::get_edition_head(&cid_hex, &entity_hex)
4631 .ok()
4632 .flatten()
4633 .map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
4634}
4635
4636fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
4644 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4645 match crate::db::community::get_server_root_epoch(&cid_hex)? {
4646 Some(held) if held != community.root_epoch.0 => Err(format!(
4647 "the community re-founded mid-action (epoch {} -> {held}); retry",
4648 community.root_epoch.0
4649 )),
4650 _ => Ok(()), }
4652}
4653
4654async fn publish_control_edition<T: Transport + ?Sized>(
4655 transport: &T,
4656 community: &CommunityV2,
4657 vsk: &str,
4658 entity_id: &[u8; 32],
4659 content: &str,
4660) -> Result<(), String> {
4661 crate::db::scoped(async move {
4662 assert_current_root(community)?;
4663 let signer = crate::signer::active_signer()?;
4664 let my_pk = me_pk()?;
4665 let view = control::ControlPlane::of(community);
4669 let control = view.write_group()?;
4670 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4671 let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
4672 let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
4673 Some((v, h)) => (v + 1, Some(h)),
4674 None => (1, None),
4675 };
4676 let citation = required_authority_citation(community, &my_pk)?;
4682 let at = now_ms() / 1000;
4683 let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
4684 let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
4685 transport.publish(&wrap, &community.relays).await?;
4686 if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
4692 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
4693 }
4694 Ok(())
4695 })
4696 .await
4697}
4698
4699fn merge_local_roster(cid_hex: &str, role: Option<&crate::community::roles::Role>, grant: Option<&crate::community::roles::MemberGrant>) {
4708 let mut roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
4709 if let Some(r) = role {
4710 match roster.roles.iter_mut().find(|x| x.role_id == r.role_id) {
4711 Some(slot) => *slot = r.clone(),
4712 None => roster.roles.push(r.clone()),
4713 }
4714 }
4715 if let Some(g) = grant {
4716 match roster.grants.iter_mut().find(|x| x.member == g.member) {
4717 Some(slot) => *slot = g.clone(),
4718 None => roster.grants.push(g.clone()),
4719 }
4720 }
4721 let at = crate::db::community::get_community_roles_at(cid_hex).unwrap_or(0);
4722 if let Err(e) = crate::db::community::set_community_roles(cid_hex, &roster, at) {
4723 crate::log_warn!("v2: local roster merge failed (heals on the next control fold): {e}");
4724 }
4725}
4726
4727pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
4731 super::roles::validate_role(role)?;
4732 let content = super::roles::role_content_json(role)?;
4733 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
4734 publish_control_edition(transport, community, vsk::ROLE, &role_id, &content).await
4735}
4736
4737pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
4743 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4744 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4745 let roster = if community.control_pk.is_some() && role_ids.iter().any(|rid| roster.role(rid).is_none()) {
4753 let mut fetched = fetch_authority(transport, community).await.roles;
4754 for r in roster.roles {
4755 if !fetched.roles.iter().any(|x| x.role_id == r.role_id) {
4756 fetched.roles.push(r);
4757 }
4758 }
4759 fetched
4760 } else {
4761 roster
4762 };
4763 grant_roles_with_roster(transport, community, member, role_ids, &roster).await
4764}
4765
4766async fn control_wrap_owed(community: &CommunityV2, makes_staff: bool, member: &PublicKey) -> Result<Option<String>, String> {
4781 if !makes_staff {
4782 return Ok(None);
4783 }
4784 let Some(pk) = community.control_pk else {
4785 return Ok(None); };
4787 let Some(cr) = community.control_root else {
4788 return Err("you don't hold this community's staff write key, so you can't promote to staff yet".to_string());
4789 };
4790 if super::derive::control_signer_group_key(&cr, community.id(), community.root_epoch).pk() != pk {
4793 return Err("this community's held staff write key is corrupt; re-sync before promoting".to_string());
4794 }
4795 use nostr_sdk::prelude::AsyncNip44;
4796 let signer = crate::signer::active_signer()?;
4797 let wrap = signer
4798 .nip44_encrypt_async(member, &super::rekey::control_wrap_b64(community.root_epoch, &cr))
4799 .await
4800 .map_err(|e| format!("staff key delivery: {e}"))?;
4801 Ok(Some(wrap))
4802}
4803
4804async fn grant_roles_with_roster<T: Transport + ?Sized>(
4808 transport: &T,
4809 community: &CommunityV2,
4810 member: &PublicKey,
4811 role_ids: Vec<String>,
4812 roster: &crate::community::roles::CommunityRoles,
4813) -> Result<(), String> {
4814 let control_wrap = control_wrap_owed(community, roster.roles_make_staff(&role_ids), member).await?;
4818 let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
4819 let content = super::roles::grant_content_json_with_wrap(&grant, control_wrap)?;
4820 let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
4821 publish_control_edition(transport, community, vsk::GRANT, &eid, &content).await
4822}
4823
4824pub async fn ensure_admin_role<T: Transport + ?Sized>(
4829 transport: &T,
4830 community: &CommunityV2,
4831 view: &AuthorityView,
4832 create_if_missing: bool,
4833) -> Result<Option<String>, String> {
4834 use crate::community::roles::{Permissions, Role, RoleScope};
4835 if let Some(r) = view
4839 .roles
4840 .roles
4841 .iter()
4842 .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK))
4843 {
4844 return Ok(Some(r.role_id.clone()));
4845 }
4846 if !create_if_missing {
4847 return Ok(None);
4848 }
4849 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4850 let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
4851 set_role(transport, community, &Role::admin(role_id.clone())).await?;
4852 Ok(Some(role_id))
4853}
4854
4855pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
4862 crate::db::scoped(async move {
4863 let my_pk = me_pk()?;
4866 if my_pk != community.owner()? {
4867 return Err("only the community owner can grant @admin".to_string());
4868 }
4869 let view = fetch_authority(transport, community).await;
4870 let member_hex = member.to_hex();
4871 require_grant_head(community, &view, &member_hex)?;
4872 let role_id = ensure_admin_role(transport, community, &view, true)
4873 .await?
4874 .expect("create_if_missing yields an id");
4875 let mut role_ids = view
4876 .roles
4877 .grants
4878 .iter()
4879 .find(|g| g.member == member_hex)
4880 .map(|g| g.role_ids.clone())
4881 .unwrap_or_default();
4882 if role_ids.contains(&role_id) {
4883 return Ok(()); }
4885 role_ids.push(role_id.clone());
4886 let mut roster = view.roles.clone();
4890 if roster.role(&role_id).is_none() {
4891 roster.roles.push(crate::community::roles::Role::admin(role_id));
4892 }
4893 grant_roles_with_roster(transport, community, member, role_ids, &roster).await
4894 })
4895 .await
4896}
4897
4898pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
4901 crate::db::scoped(async move {
4902 let my_pk = me_pk()?;
4903 if my_pk != community.owner()? {
4904 return Err("only the community owner can revoke @admin".to_string());
4905 }
4906 let view = fetch_authority(transport, community).await;
4907 let member_hex = member.to_hex();
4908 require_grant_head(community, &view, &member_hex)?;
4909 let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
4910 return Ok(()); };
4912 let mut role_ids = view
4913 .roles
4914 .grants
4915 .iter()
4916 .find(|g| g.member == member_hex)
4917 .map(|g| g.role_ids.clone())
4918 .unwrap_or_default();
4919 let before = role_ids.len();
4920 role_ids.retain(|r| r != &role_id);
4921 if role_ids.len() == before {
4922 return Ok(());
4923 }
4924 grant_roles_with_roster(transport, community, member, role_ids, &view.roles).await
4927 })
4928 .await
4929}
4930
4931fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
4935 let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
4936 return Err("malformed member key".to_string());
4937 };
4938 let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
4939 if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
4940 return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
4941 }
4942 Ok(())
4943}
4944
4945pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
4952 crate::db::scoped(async move {
4953 super::roles::validate_banlist(banned)?;
4954 {
4955 let my_pk = me_pk()?;
4956 let owner = community.owner()?;
4957 if my_pk != owner {
4958 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4959 let me_hex = my_pk.to_hex();
4960 let current = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4961 if current.contains(&me_hex) {
4962 return Err("you are banned from this community".to_string());
4963 }
4964 let roster = crate::db::community::get_community_roles(&cid_hex)?;
4965 let owner_hex = owner.to_hex();
4966 if !roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN) {
4967 return Err("editing the banlist needs the BAN permission".to_string());
4968 }
4969 for target in banned.iter().filter(|t| !current.contains(*t)) {
4972 if !roster.can_act_on_member(&me_hex, Some(&owner_hex), target, crate::community::roles::Permissions::BAN) {
4973 return Err("you do not outrank a member this ban targets".to_string());
4974 }
4975 }
4976 }
4977 }
4978 let content = super::roles::banlist_content_json(banned)?;
4979 let eid = super::derive::banlist_locator(community.id());
4980 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4981 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
4982 let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
4984 Some((v, _)) => v + 1,
4985 None => 1,
4986 };
4987 publish_control_edition(transport, community, vsk::BANLIST, &eid, &content).await?;
4988 let _ = crate::db::community::set_community_banlist(&cid_hex, banned, version as i64);
4995 Ok(())
4996 })
4997 .await
4998}
4999
5000struct ModerationLocks;
5003fn moderation_lock(cid: &crate::community::CommunityId) -> std::sync::Arc<tokio::sync::Mutex<()>> {
5004 let map = crate::db::current_session()
5005 .scoped::<ModerationLocks, std::sync::Mutex<std::collections::HashMap<[u8; 32], std::sync::Arc<tokio::sync::Mutex<()>>>>>();
5006 let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
5007 m.entry(cid.0).or_default().clone()
5008}
5009
5010async fn converge_authority<T: Transport + ?Sized>(transport: &T, id: &crate::community::CommunityId) {
5014 if let Ok(Some(fresh)) = crate::db::community::load_community_v2(id) {
5015 let _ = follow_control(transport, &fresh).await;
5016 if let Ok(added) = sync_guestbook(transport, &fresh).await {
5020 if !added.is_empty() {
5021 crate::traits::emit_event_json(
5022 "community_refreshed",
5023 serde_json::json!({ "community_id": crate::simd::hex::bytes_to_hex_32(&id.0) }),
5024 );
5025 }
5026 }
5027 }
5028}
5029
5030struct ModIntent {
5032 id: u64,
5033 targets: Vec<PublicKey>,
5034 banned: bool,
5035 done: tokio::sync::oneshot::Sender<Result<(), String>>,
5036}
5037
5038struct PendingModeration;
5041fn pending_moderation(cid: &crate::community::CommunityId) -> std::sync::Arc<std::sync::Mutex<std::collections::HashMap<[u8; 32], Vec<ModIntent>>>> {
5042 let _ = cid;
5043 crate::db::current_session()
5044 .scoped::<PendingModeration, std::sync::Mutex<std::collections::HashMap<[u8; 32], Vec<ModIntent>>>>()
5045}
5046
5047pub async fn set_members_banned<T: Transport + ?Sized>(
5068 transport: &T,
5069 community_id: &crate::community::CommunityId,
5070 members: &[PublicKey],
5071 banned: bool,
5072) -> Result<(), String> {
5073 crate::db::scoped(async move {
5074 let mut targets: Vec<PublicKey> = Vec::with_capacity(members.len());
5076 for m in members {
5077 if !targets.contains(m) {
5078 targets.push(*m);
5079 }
5080 }
5081 if targets.is_empty() {
5082 return Ok(());
5083 }
5084 static INTENT_IDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
5086 let my_id = INTENT_IDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5087 let (tx, rx) = tokio::sync::oneshot::channel();
5088 {
5089 let map = pending_moderation(community_id);
5090 let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
5091 m.entry(community_id.0).or_default().push(ModIntent { id: my_id, targets, banned, done: tx });
5092 }
5093 let unit = moderation_lock(community_id);
5094 tokio::select! {
5097 biased;
5098 res = rx => res.unwrap_or_else(|_| Err("moderation unit aborted before completing".to_string())),
5099 _guard = unit.lock() => {
5100 let batch: Vec<ModIntent> = {
5103 let map = pending_moderation(community_id);
5104 let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
5105 m.remove(&community_id.0).unwrap_or_default()
5106 };
5107 run_moderation_batch(transport, community_id, batch, my_id).await
5108 }
5109 }
5110 })
5111 .await
5112}
5113
5114async fn run_moderation_batch<T: Transport + ?Sized>(
5119 transport: &T,
5120 community_id: &crate::community::CommunityId,
5121 batch: Vec<ModIntent>,
5122 leader_id: u64,
5123) -> Result<(), String> {
5124 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
5125
5126 converge_authority(transport, community_id).await;
5131
5132 let original = crate::db::community::get_community_banlist(&cid_hex)?;
5133 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5134 let community0 = crate::db::community::load_community_v2(community_id)?
5135 .ok_or_else(|| "v2 community not found".to_string())?;
5136 let owner = community0.owner()?;
5137 let my_pk = me_pk()?;
5138 let my_hex = my_pk.to_hex();
5139 let owner_hex = owner.to_hex();
5140
5141 let mut list = original.clone();
5144 let mut pk_of: std::collections::HashMap<String, PublicKey> = std::collections::HashMap::new();
5145 let mut verdicts: std::collections::HashMap<u64, Result<(), String>> = std::collections::HashMap::new();
5146 for intent in &batch {
5147 let hexes: Vec<String> = intent.targets.iter().map(|p| p.to_hex()).collect();
5148 for (pk, hex) in intent.targets.iter().zip(&hexes) {
5149 pk_of.insert(hex.clone(), *pk);
5150 }
5151 let verdict = (|| {
5152 if my_pk != owner {
5155 if original.contains(&my_hex) {
5156 return Err("you are banned from this community".to_string());
5157 }
5158 if !roster.is_authorized(&my_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN) {
5159 return Err("editing the banlist needs the BAN permission".to_string());
5160 }
5161 if intent.banned {
5162 for t in hexes.iter().filter(|t| !list.contains(*t)) {
5163 if !roster.can_act_on_member(&my_hex, Some(&owner_hex), t, crate::community::roles::Permissions::BAN) {
5164 return Err("you do not outrank a member this ban targets".to_string());
5165 }
5166 }
5167 }
5168 }
5169 let mut next = list.clone();
5170 next.retain(|h| !hexes.contains(h));
5171 if intent.banned {
5172 next.extend(hexes.iter().cloned());
5173 }
5174 super::roles::validate_banlist(&next)?;
5175 list = next;
5176 Ok(())
5177 })();
5178 verdicts.insert(intent.id, verdict);
5179 }
5180
5181 let changed = {
5182 let mut a = original.clone();
5183 let mut b = list.clone();
5184 a.sort();
5185 b.sort();
5186 a != b
5187 };
5188 let newly_banned: Vec<PublicKey> = list
5189 .iter()
5190 .filter(|h| !original.contains(*h))
5191 .filter_map(|h| pk_of.get(h).copied())
5192 .collect();
5193
5194 let unit_result: Result<(), String> = async {
5196 if !changed {
5197 return Ok(());
5198 }
5199 let (community, stripped) = {
5203 let lock = super::realtime::follow_lock(community_id);
5204 let _rotation = lock.lock().await;
5205 let community = crate::db::community::load_community_v2(community_id)?
5206 .ok_or_else(|| "v2 community not found".to_string())?;
5207 set_banlist(transport, &community, &list).await?;
5208 let mut stripped: Vec<(PublicKey, Vec<String>)> = Vec::new();
5209 for pk in &newly_banned {
5213 let hex = pk.to_hex();
5214 let roles: Vec<String> = roster.roles_of(&hex).map(|r| r.role_id.clone()).collect();
5215 let has_grant = roster.grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
5218 if has_grant {
5219 let can_strip = my_pk == owner
5225 || roster.can_act_on_member(&my_hex, Some(&owner_hex), &hex, crate::community::roles::Permissions::MANAGE_ROLES);
5226 if can_strip {
5227 grant_roles(transport, &community, pk, vec![]).await?;
5228 } else {
5229 crate::log_warn!("[Ban] grant strip skipped (no MANAGE_ROLES over the target); the banlist still silences");
5230 }
5231 }
5232 stripped.push((*pk, roles));
5233 }
5234 (community, stripped)
5235 };
5236 if !newly_banned.is_empty() {
5237 if community_is_public(transport, &community).await {
5243 crate::log_info!("[Ban] public community — banlist + grant strip, no refound (CORD-05 §5)");
5244 match sever_banned_private_reads(transport, &community, &stripped).await {
5247 Ok(0) => {}
5248 Ok(n) => crate::log_info!("[Ban] rotated {n} private channel(s) the banned member(s) could read"),
5249 Err(e) => crate::log_warn!("[Ban] private-channel read severance incomplete: {e}"),
5250 }
5251 } else if let Err(e) = refound_community(transport, &community, &newly_banned).await {
5252 crate::log_warn!("[Ban] refound deferred (banlist + grant strip landed): {e}");
5261 }
5262 }
5263 Ok(())
5264 }
5265 .await;
5266 converge_authority(transport, community_id).await;
5267
5268 let mut my_verdict = Err("leader intent missing from its own batch".to_string());
5270 for intent in batch {
5271 let final_verdict = verdicts
5272 .remove(&intent.id)
5273 .unwrap_or_else(|| Err("intent was never judged".to_string()))
5274 .and_then(|()| unit_result.clone());
5275 if intent.id == leader_id {
5276 my_verdict = final_verdict;
5277 } else {
5278 let _ = intent.done.send(final_verdict);
5279 }
5280 }
5281 my_verdict
5282}
5283
5284#[derive(Debug, Clone, Default, serde::Serialize)]
5290pub struct ContainmentReport {
5291 pub banned: usize,
5293 pub window_cut: usize,
5295 pub epoch_before: u64,
5297 pub epoch_after: u64,
5298 pub refound_ok: bool,
5300 pub own_links_revoked: usize,
5303 pub foreign_link_creators: Vec<String>,
5305 pub warnings: Vec<String>,
5307}
5308
5309pub async fn contain_raid<T: Transport + ?Sized>(
5323 transport: &T,
5324 community_id: &crate::community::CommunityId,
5325 raiders: &[PublicKey],
5326 window_start_secs: u64,
5327 cut_also: &[PublicKey],
5328) -> Result<ContainmentReport, String> {
5329 crate::db::scoped(async move {
5330 let mut report = ContainmentReport::default();
5331 let community = crate::db::community::load_community_v2(community_id)?
5332 .ok_or_else(|| "v2 community not found".to_string())?;
5333 report.epoch_before = community.root_epoch.0;
5334 report.epoch_after = community.root_epoch.0;
5335 let owner = community.owner()?;
5336 let my_pk = me_pk()?;
5337
5338 if !raiders.is_empty() {
5345 match set_members_banned(transport, community_id, raiders, true).await {
5346 Ok(()) => report.banned = raiders.len(),
5347 Err(e) => report.warnings.push(format!("banlist did not land ({e}) — the refound still cuts every named raider")),
5348 }
5349 }
5350
5351 let community = crate::db::community::load_community_v2(community_id)?
5353 .ok_or_else(|| "v2 community not found".to_string())?;
5354
5355 let mut cut: Vec<PublicKey> = raiders.to_vec();
5365 for pk in cut_also {
5366 if !cut.contains(pk) {
5367 cut.push(*pk);
5368 report.window_cut += 1;
5369 }
5370 }
5371 if window_start_secs > 0 {
5372 let roster = crate::db::community::get_community_roles(
5373 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
5374 )
5375 .unwrap_or_default();
5376 let owner_hex = owner.to_hex();
5377 match fetch_guestbook_events(transport, &community, 0).await {
5378 Ok((events, _, _)) => {
5379 let mut first_seen: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
5382 for ev in &events {
5383 if let guestbook::GuestbookEntry::Join { member, at_ms, .. } = &ev.entry {
5384 let secs = at_ms / 1000;
5385 first_seen
5386 .entry(*member)
5387 .and_modify(|e| *e = (*e).min(secs))
5388 .or_insert(secs);
5389 }
5390 }
5391 for (pk, joined) in first_seen {
5392 if joined < window_start_secs || pk == owner || pk == my_pk || cut.contains(&pk) {
5393 continue;
5394 }
5395 if roster.is_staff(&pk.to_hex(), Some(&owner_hex)) {
5398 continue;
5399 }
5400 cut.push(pk);
5401 report.window_cut += 1;
5402 }
5403 }
5404 Err(e) => report
5405 .warnings
5406 .push(format!("guestbook unreadable — no window cut applied, only convicted raiders are severed ({e})")),
5407 }
5408 }
5409
5410 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5422 let links_before = crate::db::community::list_public_invites(&cid_hex).map(|l| l.len()).unwrap_or(0);
5423 let unit = moderation_lock(community_id);
5424 let _guard = unit.lock().await;
5425 let mut refound = Err("severing refound never attempted".to_string());
5426 for attempt in 0..3u8 {
5427 refound = refound_community_severing(transport, &community, &cut, window_start_secs).await;
5428 if refound.is_ok() {
5429 break;
5430 }
5431 if attempt < 2 {
5432 tokio::time::sleep(std::time::Duration::from_millis(400 * (attempt as u64 + 1))).await;
5433 }
5434 }
5435 match refound {
5436 Ok(updated) => {
5437 report.refound_ok = true;
5438 report.epoch_after = updated.root_epoch.0;
5439 let links_after = crate::db::community::list_public_invites(&cid_hex).map(|l| l.len()).unwrap_or(0);
5440 report.own_links_revoked = links_before.saturating_sub(links_after);
5441 if links_after > 0 {
5442 report.warnings.push(format!(
5443 "{links_after} of my own link(s) survived the sever — the mark retries them on the next follow"
5444 ));
5445 }
5446 report.foreign_link_creators = crate::db::community::invite_link_set_creators(&cid_hex)
5447 .unwrap_or_default()
5448 .into_iter()
5449 .filter(|c| *c != my_pk.to_hex())
5450 .collect();
5451 }
5452 Err(e) => {
5453 report.warnings.push(format!("SEVERING REFOUND FAILED — the invite door may still be open: {e}"));
5456 }
5457 }
5458 Ok(report)
5459 })
5460 .await
5461}
5462
5463pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
5466 ensure_folded_permission(community, &me_pk()?, crate::community::roles::Permissions::MANAGE_METADATA, "editing the community metadata")?;
5467 control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
5468 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
5469 publish_control_edition(transport, community, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
5470}
5471
5472pub async fn persist_community_image(
5477 id: &crate::community::CommunityId,
5478 img: control::ImageRef,
5479 is_banner: bool,
5480) -> Option<CommunityV2> {
5481 let lock = super::realtime::follow_lock(id);
5482 let _guard = lock.lock().await;
5483 let mut fresh = crate::db::community::load_community_v2(id).ok()??;
5484 if is_banner {
5485 fresh.banner = Some(img);
5486 } else {
5487 fresh.icon = Some(img);
5488 }
5489 crate::db::community::save_community_v2(&fresh).ok()?;
5490 Some(fresh)
5491}
5492
5493pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
5496 let my_pk = me_pk()?;
5497 ensure_channel_manager(community, &my_pk)?;
5498 let old_name = community.channel(channel_id).map(|c| c.name.clone());
5499 if meta.private {
5504 if let Some(held) = community.channel(channel_id) {
5505 if !held.private {
5506 return Err("converting a public channel to private is not supported yet".to_string());
5507 }
5508 }
5509 }
5510 control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
5511 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
5512 publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
5513 if let Ok(Some(mut held)) = crate::db::community::load_community_v2(community.id()) {
5517 if let Some(ch) = held.channels.iter_mut().find(|c| c.id.0 == channel_id.0) {
5518 ch.name = meta.name.clone();
5519 ch.private = meta.private;
5520 ch.voice = meta.voice;
5521 ch.meta_custom = meta.custom.clone();
5522 ch.meta_extra = meta.extra.clone();
5523 crate::db::community::save_community_v2(&held)?;
5524 }
5525 }
5526 if meta.private {
5528 if let Some(old) = old_name.filter(|o| *o != meta.name) {
5529 rename_channel_access_role(transport, community, channel_id, &old, &meta.name).await;
5530 }
5531 }
5532 Ok(())
5533}
5534
5535async fn rename_channel_access_role<T: Transport + ?Sized>(
5542 transport: &T,
5543 community: &CommunityV2,
5544 channel_id: &ChannelId,
5545 old_name: &str,
5546 new_name: &str,
5547) {
5548 crate::db::scoped(async move {
5549 let (Ok(my_pk), Ok(owner)) = (me_pk(), community.owner()) else {
5550 return;
5551 };
5552 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5553 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5554 let mut roster = fetch_authority(transport, community).await.roles;
5557 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5558 for r in cached.roles {
5559 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
5560 roster.roles.push(r);
5561 }
5562 }
5563 let (me_hex, owner_hex) = (my_pk.to_hex(), owner.to_hex());
5567 if !roster.is_authorized_in(&me_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
5568 return;
5569 }
5570 let Some(mut role) = roster
5573 .channel_roles(&chan_hex)
5574 .into_iter()
5575 .find(|r| r.permissions == crate::community::roles::Permissions::empty() && r.name == old_name)
5576 .cloned()
5577 else {
5578 return;
5579 };
5580 if !roster.can_act_on_position(&me_hex, Some(&owner_hex), role.position, crate::community::roles::Permissions::MANAGE_ROLES) {
5581 return;
5582 }
5583 role.name = new_name.to_string();
5584 if let Err(e) = set_role(transport, community, &role).await {
5585 crate::log_warn!("v2: channel renamed but its access role did not follow: {e}");
5586 return;
5587 }
5588 merge_local_roster(&cid_hex, Some(&role), None);
5589 })
5590 .await
5591}
5592
5593fn ensure_folded_permission(
5600 community: &CommunityV2,
5601 me: &PublicKey,
5602 needed: u64,
5603 verb: &str,
5604) -> Result<(), String> {
5605 let owner = community.owner()?;
5606 if *me == owner {
5607 return Ok(());
5608 }
5609 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5610 let me_hex = me.to_hex();
5611 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
5612 return Err("you are banned from this community".to_string());
5613 }
5614 let roster = crate::db::community::get_community_roles(&cid_hex)?;
5615 if roster.is_authorized(&me_hex, Some(&owner.to_hex()), needed) {
5616 Ok(())
5617 } else {
5618 Err(format!("{verb} needs the {} permission", permission_label(needed)))
5619 }
5620}
5621
5622fn permission_label(bits: u64) -> &'static str {
5623 use crate::community::roles::Permissions;
5624 match bits {
5625 Permissions::MANAGE_ROLES => "MANAGE_ROLES",
5626 Permissions::MANAGE_CHANNELS => "MANAGE_CHANNELS",
5627 Permissions::MANAGE_METADATA => "MANAGE_METADATA",
5628 Permissions::BAN => "BAN",
5629 Permissions::CREATE_INVITE => "CREATE_INVITE",
5630 _ => "required",
5631 }
5632}
5633
5634fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
5636 ensure_folded_permission(community, me, crate::community::roles::Permissions::MANAGE_CHANNELS, "managing channels here")
5637}
5638
5639pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
5645 let channel_id = ChannelId(super::super::random_32());
5646 create_public_channel_with_id(transport, community, name, channel_id).await?;
5647 Ok(channel_id)
5648}
5649
5650pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
5654 crate::db::scoped(async move {
5655 let lock = super::realtime::follow_lock(community.id());
5659 let _guard = lock.lock().await;
5660 let my_pk = me_pk()?;
5661 ensure_channel_manager(community, &my_pk)?;
5662 assert_channel_id_free(&channel_id, community.id())?;
5663 let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
5664 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
5665 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
5666 publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
5667 let mut updated = community.clone();
5669 updated.channels.push(ChannelV2 { id: channel_id, name: name.to_string(), private: false, key: None, epoch: updated.root_epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
5670 crate::db::community::save_community_v2(&updated)?;
5671 Ok(())
5672 })
5673 .await
5674}
5675
5676fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
5681 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5682 if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
5683 let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
5684 let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
5685 if existing != mine
5686 && matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
5687 {
5688 return Err("channel id is already live in another v2 community".to_string());
5689 }
5690 }
5691 Ok(())
5692}
5693
5694pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
5706 let channel_id = ChannelId(super::super::random_32());
5707 create_private_channel_with_id(transport, community, name, channel_id).await?;
5708 Ok(channel_id)
5709}
5710
5711pub fn channel_access_role(channel_id: &ChannelId, name: &str) -> crate::community::roles::Role {
5716 use crate::community::roles::{Permissions, Role, RoleScope};
5717 Role {
5718 role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
5719 name: name.to_string(),
5720 position: u32::MAX - 1,
5721 permissions: Permissions::empty(),
5722 scope: RoleScope::Channel(crate::simd::hex::bytes_to_hex_32(&channel_id.0)),
5723 color: 0,
5724 }
5725}
5726
5727pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
5730 crate::db::scoped(async move {
5731 let lock = super::realtime::follow_lock(community.id());
5735 let _guard = lock.lock().await;
5736 let signer = crate::signer::active_signer()?;
5737 let my_pk = me_pk()?;
5738 ensure_channel_manager(community, &my_pk)?;
5739 assert_channel_id_free(&channel_id, community.id())?;
5740 let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
5741 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
5742 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
5743
5744 let channel_key = super::super::random_32();
5745 let epoch = Epoch(1);
5746
5747 let access_role = channel_access_role(&channel_id, name);
5750 let access_role_ids = vec![access_role.role_id.clone()];
5751
5752 let owner = community.owner()?;
5756 let mut recipients = vec![my_pk];
5757 if owner != my_pk {
5758 recipients.push(owner);
5759 }
5760 let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
5761 let mut blobs = Vec::with_capacity(recipients.len());
5762 for r in &recipients {
5763 blobs.push(
5764 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
5765 .await
5766 .map_err(|e| e.to_string())?,
5767 );
5768 }
5769 let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
5770 let at_secs = now_ms() / 1000;
5771 let chunks = rekey::build_rekey_chunks(&signer, my_pk, &group, RekeyScope::Channel(channel_id), epoch, Epoch(0), &prev_commit, &blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
5772 .await
5773 .map_err(|e| e.to_string())?;
5774 for c in &chunks {
5775 transport.publish_durable(c, &community.relays).await?;
5776 }
5777 publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
5778 set_role(transport, community, &access_role).await?;
5783 let mut roster_with_access = crate::db::community::get_community_roles(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap_or_default();
5787 if roster_with_access.role(&access_role.role_id).is_none() {
5788 roster_with_access.roles.push(access_role.clone());
5789 }
5790 grant_roles_with_roster(transport, community, &my_pk, access_role_ids.clone(), &roster_with_access).await?;
5791 merge_local_roster(
5794 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
5795 Some(&access_role),
5796 Some(&crate::community::roles::MemberGrant { member: my_pk.to_hex(), role_ids: access_role_ids }),
5797 );
5798 if crate::db::community::community_protocol(community.id())?.is_none() {
5800 return Err("community removed during channel create".to_string());
5801 }
5802 let mut updated = community.clone();
5803 updated.channels.push(ChannelV2 { id: channel_id, name: name.to_string(), private: true, key: Some(channel_key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
5804 crate::db::community::save_community_v2(&updated)?;
5805 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5808 crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
5809 let _ = refresh_public_links(transport, &updated).await;
5812 Ok(())
5813 })
5814 .await
5815}
5816
5817#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5822pub enum VendVerdict {
5823 Accept,
5825 Park(&'static str),
5829 Refuse(&'static str),
5831}
5832
5833pub fn judge_channel_key_vend(
5845 community: &CommunityV2,
5846 roster: &crate::community::roles::CommunityRoles,
5847 channel_id: &ChannelId,
5848 epoch: Epoch,
5849 sender_hex: &str,
5850) -> VendVerdict {
5851 let me = match me_pk() {
5852 Ok(pk) => pk.to_hex(),
5853 Err(_) => return VendVerdict::Park("no active identity"),
5854 };
5855 let owner_hex = match community.owner() {
5856 Ok(o) => o.to_hex(),
5857 Err(_) => return VendVerdict::Refuse("community has no resolvable owner"),
5858 };
5859 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5860
5861 let Some(ch) = community.channel(channel_id) else {
5864 return VendVerdict::Park("channel not in our fold yet");
5865 };
5866 if !ch.private {
5867 return VendVerdict::Refuse("vend names a channel our fold says is public");
5870 }
5871
5872 if ch.key.is_some() && epoch.0 <= ch.epoch.0 {
5880 return VendVerdict::Refuse("superseded: we already hold this epoch or newer");
5881 }
5882 if epoch.0 > ch.epoch.0.saturating_add(MAX_VEND_EPOCH_LEAD) {
5883 return VendVerdict::Refuse("vend epoch is implausibly far ahead of the channel head");
5884 }
5885
5886 if !roster.is_entitled(Some(&owner_hex), &me, &chan_hex, &[], &[]) {
5890 return VendVerdict::Park("our grant for this channel has not folded yet");
5891 }
5892
5893 if sender_hex != owner_hex && !roster.is_entitled(Some(&owner_hex), sender_hex, &chan_hex, &[], &[]) {
5896 return VendVerdict::Park("vendor's entitlement has not folded yet");
5897 }
5898
5899 VendVerdict::Accept
5900}
5901
5902const PARKED_VEND_TTL_SECS: u64 = 30 * 24 * 3600;
5905
5906const MAX_VEND_EPOCH_LEAD: u64 = 1024;
5910
5911pub fn absorb_parked_channel_keys(community: &CommunityV2, session: &std::sync::Arc<crate::db::Session>) -> Vec<ChannelId> {
5917 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5918 let parked = match crate::db::community::get_pending_channel_keys(&cid_hex) {
5919 Ok(p) if !p.is_empty() => p,
5920 _ => return Vec::new(),
5921 };
5922 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5923 let mut adopted = Vec::new();
5924 let now = now_ms() / 1000;
5925 for p in parked {
5926 if adopted.iter().any(|c: &ChannelId| crate::simd::hex::bytes_to_hex_32(&c.0) == p.channel_id) {
5930 let _ = crate::db::community::drop_pending_channel_key(p.id);
5931 continue;
5932 }
5933 if now.saturating_sub(p.received_at.max(0) as u64) > PARKED_VEND_TTL_SECS {
5939 let _ = crate::db::community::drop_pending_channel_key(p.id);
5940 continue;
5941 }
5942 let Some(id_bytes) = crate::simd::hex::hex_to_bytes_32_checked(&p.channel_id) else {
5943 let _ = crate::db::community::drop_pending_channel_key(p.id);
5944 continue;
5945 };
5946 let channel_id = ChannelId(id_bytes);
5947 match judge_channel_key_vend(community, &roster, &channel_id, Epoch(p.epoch), &p.sender) {
5948 VendVerdict::Accept => {
5949 if !session.is_live() {
5950 return adopted;
5951 }
5952 let keyless = community.channel(&channel_id).is_some_and(|c| c.key.is_none());
5957 let seated = if keyless {
5958 crate::db::community::seat_channel_key(&cid_hex, &p.channel_id, p.epoch, &p.key)
5959 } else {
5960 crate::db::community::advance_channel_epoch(&cid_hex, &p.channel_id, p.epoch, &p.key).map(|_| ())
5961 };
5962 if let Err(e) = seated {
5963 crate::log_warn!("v2: adopting a vended channel key failed: {e}");
5964 continue;
5965 }
5966 let _ = crate::db::community::drop_pending_channel_keys_for(&cid_hex, &p.channel_id);
5968 adopted.push(channel_id);
5969 }
5970 VendVerdict::Refuse(why) => {
5971 crate::log_warn!("v2: refused a vended channel key for {}: {why}", p.channel_id);
5972 let _ = crate::db::community::drop_pending_channel_key(p.id);
5974 }
5975 VendVerdict::Park(_) => {}
5977 }
5978 }
5979 adopted
5980}
5981
5982pub async fn grant_channel_access<T: Transport + ?Sized>(
5992 transport: &T,
5993 community: &CommunityV2,
5994 channel_id: &ChannelId,
5995 member: &PublicKey,
5996) -> Result<(), String> {
5997 crate::db::scoped(async move {
5998 let my_pk = me_pk()?;
5999 let ch = community.channel(channel_id).ok_or("unknown channel")?;
6000 if !ch.private {
6001 return Err("channel is public — every member already reads it".to_string());
6002 }
6003 if ch.key.is_none() {
6004 return Err("we hold no key for this channel, so we cannot vend it".to_string());
6005 }
6006 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6007 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6008 let owner_hex = community.owner()?.to_hex();
6009 let view = fetch_authority(transport, community).await;
6015 let mut roster = view.roles.clone();
6016 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6017 for r in cached.roles {
6018 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
6019 roster.roles.push(r);
6020 }
6021 }
6022 for g in cached.grants {
6023 if !roster.grants.iter().any(|x| x.member == g.member) {
6024 roster.grants.push(g);
6025 }
6026 }
6027 let my_hex = my_pk.to_hex();
6028 let member_hex = member.to_hex();
6029 if !roster.is_authorized_in(&my_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6032 return Err("not authorized to manage this channel's access".to_string());
6033 }
6034 if member_hex != owner_hex {
6039 if !roster.can_act_on_member(&my_hex, Some(&owner_hex), &member_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6040 return Err("you do not outrank this member".to_string());
6041 }
6042 require_grant_head(community, &view, &member_hex)?;
6046 }
6047 let role_id = roster
6052 .channel_roles(&chan_hex)
6053 .into_iter()
6054 .find(|r| r.permissions == crate::community::roles::Permissions::empty())
6055 .map(|r| r.role_id.clone())
6056 .ok_or("channel has no permission-less access role to grant")?;
6057
6058 let mut role_ids: Vec<String> = roster.roles_of(&member_hex).map(|r| r.role_id.clone()).collect();
6059 if !role_ids.contains(&role_id) {
6060 role_ids.push(role_id.clone());
6061 }
6062 grant_roles_with_roster(transport, community, member, role_ids.clone(), &roster).await?;
6065 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids }));
6066 let bundle = bundle_of_with_overlay(
6068 community,
6069 BundleAudience::Member(*member),
6070 Some(my_pk),
6071 None,
6072 None,
6073 std::slice::from_ref(&role_id),
6074 &[],
6075 );
6076 let signer = crate::signer::active_signer()?;
6077 let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
6078 transport.publish(&wrap, &community.relays).await?;
6079 Ok(())
6080 })
6081 .await
6082}
6083
6084pub async fn revoke_channel_access<T: Transport + ?Sized>(
6090 transport: &T,
6091 community: &CommunityV2,
6092 channel_id: &ChannelId,
6093 member: &PublicKey,
6094) -> Result<(), String> {
6095 crate::db::scoped(async move {
6096 let my_pk = me_pk()?;
6097 let ch = community.channel(channel_id).ok_or("unknown channel")?;
6098 if !ch.private {
6099 return Err("channel is public — there is no access to revoke".to_string());
6100 }
6101 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6102 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6103 let owner_hex = community.owner()?.to_hex();
6104 let view = fetch_authority(transport, community).await;
6107 let mut roster = view.roles.clone();
6108 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6109 for r in cached.roles {
6110 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
6111 roster.roles.push(r);
6112 }
6113 }
6114 for g in cached.grants {
6115 if !roster.grants.iter().any(|x| x.member == g.member) {
6116 roster.grants.push(g);
6117 }
6118 }
6119 let my_hex = my_pk.to_hex();
6120 let member_hex = member.to_hex();
6121 if !roster.is_authorized_in(&my_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6122 return Err("not authorized to manage this channel's access".to_string());
6123 }
6124 if *member == community.owner()? {
6125 return Err("the owner is supreme and cannot be removed".to_string());
6126 }
6127 if !roster.can_act_on_member(&my_hex, Some(&owner_hex), &member_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6131 return Err("you do not outrank this member".to_string());
6132 }
6133 require_grant_head(community, &view, &member_hex)?;
6135 let access_ids = roster.channel_role_ids(&chan_hex);
6136 if access_ids.is_empty() {
6140 return Err("this channel's access role has not folded yet — retry once the control plane serves it".to_string());
6141 }
6142 let remaining: Vec<String> = roster
6143 .roles_of(&member_hex)
6144 .map(|r| r.role_id.clone())
6145 .filter(|id| !access_ids.contains(id))
6146 .collect();
6147 grant_roles_with_roster(transport, community, member, remaining.clone(), &roster).await?;
6148 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids: remaining }));
6149 rekey_channel_excluding(transport, community, channel_id, &roster, &access_ids, std::slice::from_ref(member)).await
6153 })
6154 .await
6155}
6156
6157async fn rekey_channel_excluding<T: Transport + ?Sized>(
6168 transport: &T,
6169 community: &CommunityV2,
6170 channel_id: &ChannelId,
6171 roster: &crate::community::roles::CommunityRoles,
6172 access_ids: &[String],
6173 removed: &[PublicKey],
6174) -> Result<(), String> {
6175 crate::db::scoped(async move {
6176 let lock = super::realtime::follow_lock(community.id());
6178 let _guard = lock.lock().await;
6179 let signer = crate::signer::active_signer()?;
6180 let my_pk = me_pk()?;
6181 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6182 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6183 let ch = community.channel(channel_id).ok_or("unknown channel")?.clone();
6184 let old_key = ch.key.ok_or("we hold no key for this channel, so we cannot rotate it")?;
6185 let new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
6186 let owner = community.owner()?;
6187 let owner_hex = owner.to_hex();
6188
6189 let removed_hexes: std::collections::HashSet<String> = removed.iter().map(|p| p.to_hex()).collect();
6193 let mut recipients: Vec<PublicKey> = vec![my_pk];
6194 if owner != my_pk {
6195 recipients.push(owner);
6196 }
6197 for g in &roster.grants {
6198 if removed_hexes.contains(&g.member) || g.member == owner_hex {
6199 continue;
6200 }
6201 if !g.role_ids.iter().any(|id| access_ids.contains(id)) {
6202 continue;
6203 }
6204 if let Ok(pk) = PublicKey::parse(&g.member) {
6205 if !recipients.contains(&pk) {
6206 recipients.push(pk);
6207 }
6208 }
6209 }
6210 let new_key = mint_or_reuse_rotation_key(&cid_hex, &chan_hex, new_epoch.0)?;
6213 let prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
6214 let mut blobs = Vec::with_capacity(recipients.len());
6215 for r in &recipients {
6216 blobs.push(
6217 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(*channel_id), new_epoch, &new_key)
6218 .await
6219 .map_err(|e| e.to_string())?,
6220 );
6221 }
6222 let group = channel_rekey_group_key(&community.community_root, channel_id, new_epoch);
6223 let at_secs = now_ms() / 1000;
6224 let chunks = rekey::build_rekey_chunks(&signer, my_pk, &group, RekeyScope::Channel(*channel_id), new_epoch, ch.epoch, &prev_commit, &blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
6225 .await
6226 .map_err(|e| e.to_string())?;
6227 for c in &chunks {
6228 transport.publish_durable(c, &community.relays).await?;
6229 }
6230 if crate::db::community::community_protocol(community.id())?.is_none() {
6231 return Err("community removed during channel rekey".to_string());
6232 }
6233 crate::db::community::advance_channel_epoch(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
6235 crate::db::community::store_epoch_key(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
6236
6237 {
6244 let mut rotated = community.clone();
6245 if let Some(c) = rotated.channels.iter_mut().find(|c| c.id == *channel_id) {
6246 c.key = Some(new_key);
6247 c.epoch = new_epoch;
6248 }
6249 match read_channel_pins(&rotated, channel_id) {
6250 Ok(read) if !read.sealed && !read.pins.is_empty() => {
6251 let entries: Vec<super::pins::PinEntry> =
6252 read.pins.iter().map(|p| p.entry.clone()).collect();
6253 if let Some(ch2) = rotated.channel(channel_id).cloned() {
6254 if let Err(e) = publish_pin_list(transport, &rotated, &ch2, &entries).await {
6255 crate::log_warn!("[pins] rotation reseal failed (a curator's next edit heals): {e}");
6256 } else {
6257 crate::log_info!("[pins] resealed {} pin(s) under epoch {}", entries.len(), new_epoch.0);
6258 }
6259 }
6260 }
6261 Ok(read) if read.sealed => {
6262 crate::log_warn!("[pins] rotating a channel whose pin list we cannot read; reseal skipped");
6263 }
6264 _ => {}
6265 }
6266 }
6267 Ok(())
6268 })
6269 .await
6270}
6271
6272pub async fn sever_banned_private_reads<T: Transport + ?Sized>(
6292 transport: &T,
6293 community: &CommunityV2,
6294 targets: &[(PublicKey, Vec<String>)],
6295) -> Result<usize, String> {
6296 crate::db::scoped(async move {
6297 let my_pk = me_pk()?;
6298 ensure_folded_permission(community, &my_pk, crate::community::roles::Permissions::MANAGE_CHANNELS, "severing a banned member's channel reads")?;
6299 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6300 let mut roster = fetch_authority(transport, community).await.roles;
6304 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6305 for r in cached.roles {
6306 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
6307 roster.roles.push(r);
6308 }
6309 }
6310 for g in cached.grants {
6311 if !roster.grants.iter().any(|x| x.member == g.member) {
6312 roster.grants.push(g);
6313 }
6314 }
6315 let owner_hex = community.owner().ok().map(|o| o.to_hex());
6316 let mut rotated = 0usize;
6317 let mut failures: Vec<String> = Vec::new();
6318 for ch in &community.channels {
6319 if !ch.private || ch.key.is_none() {
6320 continue;
6321 }
6322 let chan_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
6323 let excluded: Vec<PublicKey> = targets
6325 .iter()
6326 .filter(|(pk, stripped)| roster.is_entitled(owner_hex.as_deref(), &pk.to_hex(), &chan_hex, stripped, &[]))
6327 .map(|(pk, _)| *pk)
6328 .collect();
6329 if excluded.is_empty() {
6330 continue;
6331 }
6332 let access_ids = roster.channel_role_ids(&chan_hex);
6333 if access_ids.is_empty() {
6336 continue;
6337 }
6338 let held = match crate::db::community::load_community_v2(community.id()) {
6341 Ok(Some(c)) => c,
6342 _ => return Err("community gone during ban severance".to_string()),
6343 };
6344 match rekey_channel_excluding(transport, &held, &ch.id, &roster, &access_ids, &excluded).await {
6345 Ok(()) => rotated += 1,
6346 Err(e) => failures.push(format!("{}: {e}", &chan_hex[..12])),
6347 }
6348 }
6349 if failures.is_empty() {
6350 Ok(rotated)
6351 } else {
6352 Err(format!("{rotated} rotated; failed: {}", failures.join("; ")))
6353 }
6354 })
6355 .await
6356}
6357
6358pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
6361 crate::db::scoped(async move {
6362 let lock = super::realtime::follow_lock(community.id());
6364 let _guard = lock.lock().await;
6365 let my_pk = me_pk()?;
6366 ensure_channel_manager(community, &my_pk)?;
6367 let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
6371 name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
6372 });
6373 meta.deleted = Some(true);
6374 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
6375 publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
6376 let mut updated = community.clone();
6377 updated.channels.retain(|c| c.id.0 != channel_id.0);
6378 crate::db::community::save_community_v2(&updated)?;
6379 Ok(())
6380 })
6381 .await
6382}
6383
6384#[derive(Debug, Default)]
6417pub struct ControlFollow {
6418 pub updated: Option<CommunityV2>,
6419 pub authority_changed: bool,
6420}
6421
6422impl ControlFollow {
6423 pub fn moved(&self) -> bool {
6425 self.updated.is_some() || self.authority_changed
6426 }
6427}
6428
6429pub async fn follow_control<T: Transport + ?Sized>(
6430 transport: &T,
6431 community: &CommunityV2,
6432) -> Result<ControlFollow, String> {
6433 crate::db::scoped(async move {
6434 community.owner()?; let control = control::ControlPlane::of(community);
6436 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6437
6438 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
6444 .into_iter()
6445 .filter(|(_, f)| f.0 == community.root_epoch.0)
6446 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
6447 .collect();
6448
6449 let mut editions: Vec<ParsedEdition> = Vec::new();
6453 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
6454 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
6455 let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false, pins_persist: Vec::new() };
6456 let mut authority = AuthoritySet::owner_only();
6457 let mut truncated = true;
6463 let mut pages_read = 0usize;
6464 for tier in [
6473 crate::community::transport::Evidence::Quorum,
6474 crate::community::transport::Evidence::Full,
6475 ] {
6476 let mut until: Option<u64> = None;
6477 let mut oldest: Option<u64> = None;
6478 truncated = true;
6479 for _ in 0..FOLLOW_MAX_PAGES {
6480 pages_read += 1;
6481 let query = Query {
6482 kinds: vec![stream::KIND_WRAP],
6483 authors: vec![control.pk_hex()],
6484 until,
6485 limit: Some(FOLLOW_PAGE),
6486 evidence: tier,
6487 ..Default::default()
6488 };
6489 let wraps = transport.fetch(&query, &community.relays).await?;
6490 let mut fresh = 0usize;
6494 for w in &wraps {
6495 if !seen_wraps.insert(w.id) {
6496 continue;
6497 }
6498 fresh += 1;
6499 let at = w.created_at.as_secs();
6500 if oldest.is_none_or(|o| at < o) {
6501 oldest = Some(at);
6502 }
6503 if let Ok((ed, _)) = control.open(w) {
6506 if seen.insert(ed.inner_id) {
6507 editions.push(ed);
6508 }
6509 }
6510 }
6511 authority = fold_authority(community, &editions, &floors);
6514 fold = apply_control_fold(community, &editions, &floors, &authority);
6515 let heads_now: std::collections::HashSet<&str> =
6529 authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
6530 let all_accounted = match crate::db::community::get_community_roles(&cid_hex) {
6535 Ok(known) => {
6536 (!known.roles.is_empty() || !known.grants.is_empty())
6543 && known.roles.iter().all(|r| heads_now.contains(r.role_id.as_str()))
6544 && known.grants.iter().all(|g| {
6545 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
6546 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
6547 heads_now.contains(eid.as_str())
6548 })
6549 })
6550 }
6551 Err(e) => {
6552 crate::log_warn!("[v2:control {}] roster read failed mid-page ({e}) — paging on rather than assuming nothing is tracked", &cid_hex[..8.min(cid_hex.len())]);
6553 false
6554 }
6555 };
6556 if !(fold.gapped || authority.gapped) && all_accounted {
6557 truncated = false; break;
6559 }
6560 if fresh == 0 {
6561 truncated = wraps.len() >= FOLLOW_PAGE;
6566 break;
6567 }
6568 until = oldest;
6569 }
6570
6571 if tier == crate::community::transport::Evidence::Full {
6572 break;
6573 }
6574 let stored_so_far = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6575 let heads_so_far: std::collections::HashSet<&str> =
6576 authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
6577 let accounted = stored_so_far.roles.iter().all(|r| !floors.contains_key(&r.role_id) || heads_so_far.contains(r.role_id.as_str()))
6578 && stored_so_far.grants.iter().all(|g| {
6579 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
6580 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
6581 !floors.contains_key(&eid) || heads_so_far.contains(eid.as_str())
6582 })
6583 });
6584 if accounted {
6585 break;
6586 }
6587 crate::log_debug!(
6588 "[v2:control {}] {} stored entit(ies) unaccounted after a Quorum read — re-reading at Full before drawing an absence verdict",
6589 &cid_hex[..8.min(cid_hex.len())],
6590 stored_so_far.roles.len() + stored_so_far.grants.len()
6591 );
6592 }
6593
6594 if crate::db::community::community_protocol(community.id())?.is_none() {
6599 return Ok(ControlFollow::default());
6600 }
6601 for h in fold.heads.iter().chain(authority.heads.iter()) {
6607 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, community.root_epoch.0)?;
6608 crate::db::community::converge_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, community.root_epoch.0)?;
6609 }
6610 let adopted_control_root = adopt_my_control_wrap(community, &editions).await;
6617 let mut authority_changed = false;
6622 let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
6626 if let Some((banned, version)) = &authority.banlist_persist {
6627 let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
6628 crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
6629 let mut after = banned.clone();
6630 before.sort();
6631 after.sort();
6632 authority_changed |= before != after;
6633 }
6634 let newest_roster_at: i64 = editions
6641 .iter()
6642 .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
6643 .map(|e| e.created_at as i64)
6644 .max()
6645 .unwrap_or(0);
6646 let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6652 let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
6653 let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
6654 && stored.grants.iter().all(|g| {
6655 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
6656 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
6657 !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
6658 })
6659 });
6660 let stored_at = crate::db::community::get_community_roles_at(&cid_hex)?;
6665 let seed_only = stored_at == 0;
6674 if !truncated && (stored_complete || seed_only) && newest_roster_at >= stored_at {
6711 authority_changed |= stored != authority.roles;
6712 crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
6713 } else {
6714 let mut blockers: Vec<String> = Vec::new();
6722 for r in &stored.roles {
6723 if floors.contains_key(&r.role_id) && !head_ents.contains(r.role_id.as_str()) {
6724 blockers.push(format!("role:{}", &r.role_id[..8.min(r.role_id.len())]));
6725 }
6726 }
6727 for g in &stored.grants {
6728 if let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) {
6729 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
6730 if floors.contains_key(&eid) && !head_ents.contains(eid.as_str()) {
6731 blockers.push(format!("grant:{}", &g.member[..8.min(g.member.len())]));
6732 }
6733 }
6734 }
6735 {
6740 use std::collections::BTreeMap;
6741 let mut fate: BTreeMap<String, (u64, u64, String, &str)> = BTreeMap::new();
6742 for e in &editions {
6743 if e.vsk != vsk::ROLE && e.vsk != vsk::GRANT {
6744 continue;
6745 }
6746 let kind = if e.vsk == vsk::ROLE { "role" } else { "grant" };
6747 let eid = crate::simd::hex::bytes_to_hex_32(&e.entity_id);
6748 let slot = fate.entry(eid).or_insert((0, 0, String::new(), kind));
6749 slot.0 += 1;
6750 if e.version > slot.1 {
6751 slot.1 = e.version;
6752 slot.2 = e.author.to_hex()[..8].to_string();
6753 slot.3 = kind;
6754 }
6755 }
6756 let head_now: std::collections::HashSet<&str> =
6757 authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
6758 let rows: Vec<String> = fate
6759 .iter()
6760 .map(|(eid, (n, v, author, kind))| {
6761 format!(
6762 "{}[{kind}]:{}ed/v{}/by{}{}{}",
6763 &eid[..8], n, v, author,
6764 if head_now.contains(eid.as_str()) {
6765 "/HEAD".to_string()
6766 } else {
6767 AUTHORITY_REFUSALS
6768 .with(|c| c.borrow().get(eid.as_str()).copied())
6769 .map(|r| {
6770 let extra = MISSING_ROLE_REFS
6771 .with(|m| m.borrow().get(eid.as_str()).cloned())
6772 .map(|ids| format!(" wants:{ids}"))
6773 .unwrap_or_default();
6774 format!("/DROPPED({r}{extra})")
6775 })
6776 .unwrap_or_else(|| "/dropped(no candidate survived the fold)".into())
6777 },
6778 floors.get(eid).map(|f| format!("/floor@v{}", f.0)).unwrap_or_default(),
6779 )
6780 })
6781 .collect();
6782 crate::log_warn!(
6787 "[v2:control {}] fold accepted roles: [{}]",
6788 &cid_hex[..8.min(cid_hex.len())],
6789 authority.roles.roles.iter().map(|r| format!("{}@pos{}", &r.role_id[..8.min(r.role_id.len())], r.position)).collect::<Vec<_>>().join(" ")
6790 );
6791 crate::log_warn!(
6792 "[v2:control {}] owner={} — roster editions in window: [{}]",
6793 &cid_hex[..8.min(cid_hex.len())],
6794 community.owner().map(|o| o.to_hex()[..8].to_string()).unwrap_or_else(|_| "UNPROVEN".into()),
6795 rows.join(" ")
6796 );
6797 }
6798 crate::log_warn!(
6799 "[v2:control {}] roster NOT cached (truncated={} authority_gapped={} doc_gapped={} stored_complete={} newest_roster_at={} stored_at={}) — stored {}r/{}g, fold offers {}r/{}g from {} edition(s) over {} page(s), blocked by [{}]",
6800 &cid_hex[..8.min(cid_hex.len())], truncated, authority.gapped, fold.gapped, stored_complete, newest_roster_at, stored_at,
6801 stored.roles.len(), stored.grants.len(), authority.roles.roles.len(), authority.roles.grants.len(),
6802 editions.len(), pages_read,
6803 blockers.join(", ")
6804 );
6805 }
6806 if !truncated && !authority.gapped && !fold.gapped {
6812 if let Ok(owner) = community.owner() {
6813 let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
6814 let live = flatten_link_sets(&sets);
6815 let mut before = crate::db::community::get_community_invite_registry(&cid_hex).unwrap_or_default();
6816 before.sort();
6817 if before != live {
6818 crate::db::community::set_community_invite_registry(&cid_hex, &live)?;
6819 authority_changed = true;
6820 }
6821 crate::db::community::replace_invite_link_sets(&cid_hex, &sets)?;
6824 }
6825 }
6826 for (channel_hex, content, version, author_npub, created_at) in &fold.pins_persist {
6830 match crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64) {
6831 Ok(true) => {
6832 crate::log_info!("[pins] fold adopted v{} for channel {}", version, &channel_hex[..12]);
6833 crate::emit_event(
6834 "community_pins_updated",
6835 &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
6836 );
6837 note_pins_modified(channel_hex, *version, author_npub, *created_at).await;
6838 }
6839 Ok(false) => {}
6840 Err(e) => crate::log_warn!("[pins] fold persist failed: {e}"),
6841 }
6842 }
6843 if authority_changed {
6848 crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
6849 }
6850 {
6854 static UPGRADED: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
6855 let first = UPGRADED
6856 .lock()
6857 .map(|mut set| set.get_or_insert_with(Default::default).insert(cid_hex.clone()))
6858 .unwrap_or(false);
6859 if first {
6860 let _ = upgrade_admin_role_pin_bit(transport, community).await;
6861 }
6862 }
6863 let mut updated = fold.updated;
6867 if let Some(cr) = adopted_control_root {
6868 let mut u = updated.take().unwrap_or_else(|| community.clone());
6869 u.control_root = Some(cr);
6870 updated = Some(u);
6871 }
6872 match updated {
6873 Some(u) => {
6874 crate::db::community::save_community_v2(&u)?;
6875 Ok(ControlFollow { updated: Some(u), authority_changed })
6876 }
6877 None => Ok(ControlFollow { updated: None, authority_changed }),
6878 }
6879 })
6880 .await
6881}
6882
6883async fn adopt_my_control_wrap(community: &CommunityV2, editions: &[ParsedEdition]) -> Option<[u8; 32]> {
6894 use nostr_sdk::prelude::AsyncNip44;
6895 let held_pk = community.control_pk?;
6896 if community.control_root.is_some() {
6897 return None;
6898 }
6899 let my_pk = me_pk().ok()?;
6900 let signer = crate::signer::active_signer().ok()?;
6901 let my_eid = super::derive::grant_locator(community.id(), &my_pk.to_bytes());
6902 let mut candidates: Vec<&ParsedEdition> = editions
6903 .iter()
6904 .filter(|e| e.vsk == vsk::GRANT && e.entity_id == my_eid)
6905 .collect();
6906 candidates.sort_by(|a, b| b.version.cmp(&a.version));
6907 for ed in candidates {
6908 let Some(wrap_b64) = super::roles::parse_grant_control_wrap(&ed.content) else { continue };
6909 let Ok(plain_b64) = signer.nip44_decrypt_async(&ed.author, &wrap_b64).await else { continue };
6912 let Ok(plain) = base64_simd::STANDARD.decode_to_vec(plain_b64.as_bytes()) else { continue };
6913 let Ok((epoch, root)) = super::rekey::parse_control_wrap(&plain) else { continue };
6914 if epoch != community.root_epoch {
6915 continue;
6916 }
6917 if super::derive::control_signer_group_key(&root, community.id(), epoch).pk() != held_pk {
6918 continue;
6919 }
6920 crate::log_info!("[v2] adopted the staff write key from my Grant (epoch {})", epoch.0);
6921 return Some(root);
6922 }
6923 None
6924}
6925
6926const MAX_WALK_STEPS: usize = 128;
6937const FOLLOW_MAX_PAGES: usize = 32;
6938const FOLLOW_PAGE: usize = 500;
6939const COMPACT_MAX_PAGES: usize = 512;
6944
6945#[derive(Clone)]
6947struct FoldedHead {
6948 entity_hex: String,
6949 version: u64,
6950 self_hash: [u8; 32],
6951 inner_id: [u8; 32],
6952}
6953
6954struct ControlFold {
6960 updated: Option<CommunityV2>,
6961 heads: Vec<FoldedHead>,
6962 gapped: bool,
6963 pins_persist: Vec<(String, String, u64, String, u64)>,
6967}
6968
6969type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
6971
6972fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
6993 use crate::community::roles::Permissions;
6994 use std::collections::BTreeMap;
6995
6996 let owner_hex = community.owner().ok().map(|o| o.to_hex());
6997
6998 let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
6999 for e in editions {
7000 groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
7001 }
7002
7003 let mut out = community.clone();
7004 let mut changed = false;
7005 let mut heads = Vec::new();
7006 let mut gapped = false;
7007 let mut pins_persist = Vec::new();
7008 let pins_by_eid: std::collections::HashMap<[u8; 32], String> = community
7013 .channels
7014 .iter()
7015 .map(|ch| (super::derive::pins_locator(community.id(), &ch.id), crate::simd::hex::bytes_to_hex_32(&ch.id.0)))
7016 .collect();
7017 for ((vsk_code, eid), group) in &groups {
7018 let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
7024 let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
7025 let pins_channel = (vsk_code == vsk::PINS).then(|| pins_by_eid.get(eid)).flatten();
7026 if !is_meta && !is_channel && pins_channel.is_none() {
7027 continue;
7028 }
7029 let required = if is_meta {
7034 Permissions::MANAGE_METADATA
7035 } else if is_channel {
7036 Permissions::MANAGE_CHANNELS
7037 } else {
7038 Permissions::PIN_MESSAGES
7039 };
7040 let authed: Vec<&ParsedEdition> = group
7041 .iter()
7042 .copied()
7043 .filter(|e| {
7044 let author = e.author.to_hex();
7045 !authority.banned.contains(&author)
7048 && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
7049 && citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
7053 })
7054 .collect();
7055 if authed.is_empty() {
7056 continue;
7057 }
7058 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
7059 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
7060 let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
7061 gapped |= entity_gapped;
7062 let Some(hi) = hi else { continue };
7063
7064 let head = authed[hi];
7065 heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
7066 if is_meta {
7067 if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
7068 changed |= apply_community_metadata(&mut out, meta);
7069 }
7070 } else if is_channel {
7071 if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
7072 changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
7076 }
7077 } else if let Some(channel_hex) = pins_channel {
7078 use nostr_sdk::prelude::ToBech32;
7082 let author_npub = head
7083 .author
7084 .to_bech32()
7085 .unwrap_or_else(|_| head.author.to_hex());
7086 pins_persist.push((channel_hex.clone(), head.content.clone(), head.version, author_npub, head.created_at));
7087 }
7088 }
7089 ControlFold { updated: changed.then_some(out), heads, gapped, pins_persist }
7090}
7091
7092fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
7099 let floor_v = floor.map(|f| f.0).unwrap_or(0);
7100 if floor_v == 0 {
7101 return (version::bootstrap_head(fold_eds, 0), false);
7102 }
7103 let floor_hash = floor.map(|f| &f.1);
7104 let held_inner = floor.and_then(|f| f.2);
7105 let result = version::fold(fold_eds, floor_v, floor_hash);
7106 if result.anchored {
7107 return (result.head, result.gap); }
7109 if result.head.is_none() && !result.gap {
7110 return (None, false); }
7112 let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
7116 let win_hash = match fork {
7117 Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
7118 _ => return (None, true), };
7120 let re = version::fold(fold_eds, floor_v, Some(&win_hash));
7121 if !re.anchored {
7122 return (None, true);
7123 }
7124 (re.head, re.gap)
7125}
7126
7127struct AuthoritySet {
7132 roles: crate::community::roles::CommunityRoles,
7133 banned: std::collections::BTreeSet<String>,
7134 heads: Vec<FoldedHead>,
7135 gapped: bool,
7136 banlist_persist: Option<(Vec<String>, u64)>,
7140 banned_at: std::collections::BTreeMap<String, u64>,
7144}
7145
7146impl AuthoritySet {
7147 fn owner_only() -> Self {
7150 AuthoritySet {
7151 roles: Default::default(),
7152 banned: Default::default(),
7153 heads: vec![],
7154 gapped: false,
7155 banlist_persist: None,
7156 banned_at: Default::default(),
7157 }
7158 }
7159}
7160
7161fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
7168 use crate::community::roles::Permissions;
7169 use std::collections::BTreeMap;
7170
7171 let cid = community.id();
7172 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
7173 let owner = community.owner().ok();
7174 let owner_hex = owner.map(|o| o.to_hex());
7175 let banlist_eid = super::derive::banlist_locator(cid);
7176 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
7177
7178 let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
7179 for e in editions {
7180 if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
7181 groups.entry(e.entity_id).or_default().push(e);
7182 }
7183 }
7184
7185 let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
7193 let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
7194 let mut gapped = false;
7195
7196 for (eid, group) in &groups {
7197 if *eid == banlist_eid {
7199 continue;
7200 }
7201 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
7202 let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
7203 let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
7204 gapped |= entity_gapped;
7205 let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
7206
7207 for p in group {
7208 if p.version < floor_v {
7210 continue;
7211 }
7212 let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
7213 match p.vsk.as_str() {
7214 vsk::ROLE => {
7215 if let Some(role) = super::roles::parse_role_content(&p.content) {
7217 if role.role_id == entity_hex && role.position != 0 {
7218 role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
7219 }
7220 }
7221 }
7222 vsk::GRANT => {
7223 if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
7224 if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
7225 if super::derive::grant_locator(cid, &member) == *eid {
7226 grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
7227 grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
7228 }
7229 }
7230 }
7231 }
7232 _ => {}
7233 }
7234 }
7235 }
7236 for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
7237 cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
7238 }
7239
7240 let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
7241 let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty, None);
7244
7245 let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
7256 let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
7261 let banlist_authored: Vec<&ParsedEdition> = groups
7262 .get(&banlist_eid)
7263 .map(|g| {
7264 g.iter()
7265 .copied()
7266 .filter(|e| {
7267 let ah = e.author.to_hex();
7268 !banned_authors.contains(ah.as_str())
7269 && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
7270 && citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
7271 })
7272 .collect()
7273 })
7274 .unwrap_or_default();
7275 let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
7281 for p in &banlist_authored {
7282 for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
7283 if owner_hex.as_deref() == Some(t.as_str()) {
7284 continue;
7285 }
7286 let slot = banned_at.entry(t).or_insert(0);
7287 *slot = (*slot).max(p.created_at);
7288 }
7289 }
7290 let mut banlist_persist: Option<(Vec<String>, u64)> = None;
7291 let mut banlist_head: Option<FoldedHead> = None;
7292 let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
7293 persisted_banned.into_iter().collect()
7294 } else {
7295 let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
7296 let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
7297 gapped |= g;
7298 match hi {
7299 Some(hi) => {
7300 let head = banlist_authored[hi];
7301 let ah = head.author.to_hex();
7302 let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
7303 .unwrap_or_default()
7304 .into_iter()
7305 .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
7306 .take(super::roles::MAX_BANLIST)
7307 .collect();
7308 banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
7309 banlist_persist = Some((list.clone(), head.version));
7310 list.into_iter().collect()
7311 }
7312 None => persisted_banned.into_iter().collect(),
7313 }
7314 };
7315
7316 UNCITED_ACCEPTED.with(|n| *n.borrow_mut() = 0);
7320 let mut refusals: std::collections::BTreeMap<String, &'static str> = Default::default();
7321 let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned, Some(&mut refusals));
7322 AUTHORITY_REFUSALS.with(|c| *c.borrow_mut() = refusals);
7323 if let Some(bh) = banlist_head {
7324 heads.push(bh);
7325 }
7326
7327 if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
7332 authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
7333 authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
7334 let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
7335 authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
7336 authorized.grants.retain(|g| !g.role_ids.is_empty());
7337 }
7338
7339 AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
7340}
7341
7342struct AuthorityCand {
7345 role: Option<crate::community::roles::Role>,
7346 grant: Option<crate::community::roles::MemberGrant>,
7347 author: PublicKey,
7348 head: FoldedHead,
7349 citation: Option<crate::community::edition::AuthorityCitation>,
7352}
7353
7354fn citation_ok_in_fold(
7363 cid: &crate::community::CommunityId,
7364 heads: &[FoldedHead],
7365 owner_hex: Option<&str>,
7366 author: &PublicKey,
7367 citation: Option<&crate::community::edition::AuthorityCitation>,
7368) -> bool {
7369 let actor_hex = author.to_hex();
7370 if owner_hex == Some(actor_hex.as_str()) {
7371 return true;
7372 }
7373 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
7374 let as_entity: Vec<crate::community::roster::EntityHead> = heads
7375 .iter()
7376 .map(|h| crate::community::roster::EntityHead {
7377 entity_hex: h.entity_hex.clone(),
7378 version: h.version,
7379 self_hash: h.self_hash,
7380 inner_id: h.inner_id,
7381 citation: None,
7382 })
7383 .collect();
7384 crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
7385}
7386
7387thread_local! {
7388 static UNCITED_ACCEPTED: std::cell::RefCell<u32> = const { std::cell::RefCell::new(0) };
7391 static MISSING_ROLE_REFS: std::cell::RefCell<std::collections::BTreeMap<String, String>> =
7393 const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
7394 static AUTHORITY_REFUSALS: std::cell::RefCell<std::collections::BTreeMap<String, &'static str>> =
7397 const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
7398}
7399
7400fn select_authorized(
7409 cid: &crate::community::CommunityId,
7410 role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
7411 grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
7412 owner_hex: Option<&str>,
7413 excluded: &std::collections::BTreeSet<String>,
7414 mut why: Option<&mut std::collections::BTreeMap<String, &'static str>>,
7418) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
7419 use crate::community::roles::{CommunityRoles, Permissions};
7420 let mut accepted = CommunityRoles::default();
7421 let mut heads: Vec<FoldedHead> = Vec::new();
7422 let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
7427 for _ in 0..bound {
7428 let mut next = CommunityRoles::default();
7429 let mut next_heads: Vec<FoldedHead> = Vec::new();
7430
7431 for cands in role_cands.values() {
7432 let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
7445 let mut standing: Option<u32> = None;
7446 let mut i = cands.len();
7447 while i > 0 {
7448 let hi = i;
7449 let ver = cands[i - 1].head.version;
7450 while i > 0 && cands[i - 1].head.version == ver {
7451 i -= 1;
7452 }
7453 for c in cands[i..hi].iter().rev() {
7455 let Some(role) = &c.role else { continue };
7456 let ah = c.author.to_hex();
7457 let note = |w: &mut Option<&mut std::collections::BTreeMap<String, &'static str>>, r: &'static str| {
7458 if let Some(m) = w.as_mut() {
7459 m.insert(c.head.entity_hex.clone(), r);
7460 }
7461 };
7462 if excluded.contains(&ah) || role.position == 0 {
7463 note(&mut why, "role: author banned or position 0");
7464 continue;
7465 }
7466 if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
7467 note(&mut why, "role: author lacks MANAGE_ROLES over this position");
7468 continue;
7469 }
7470 if let Some(prev) = standing {
7471 if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
7472 note(&mut why, "role: author cannot outrank the position it replaces");
7473 continue;
7474 }
7475 }
7476 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
7488 if c.citation.is_some() {
7489 note(&mut why, "role: cited a grant we cannot resolve");
7490 continue;
7491 }
7492 UNCITED_ACCEPTED.with(|n| *n.borrow_mut() += 1);
7493 }
7494 admissible.insert(c.head.self_hash);
7495 standing = Some(role.position);
7496 break;
7497 }
7498 }
7499 for c in cands {
7500 let Some(role) = &c.role else { continue };
7501 if !admissible.contains(&c.head.self_hash) {
7502 continue;
7503 }
7504 next.roles.push(role.clone());
7505 next_heads.push(c.head.clone());
7506 break; }
7508 }
7509 for cands in grant_cands.values() {
7510 for c in cands {
7511 let c2 = c;
7512 let Some(grant) = &c.grant else { continue };
7513 let ah = c.author.to_hex();
7514 let note = |w: &mut Option<&mut std::collections::BTreeMap<String, &'static str>>, r: &'static str| {
7515 if let Some(m) = w.as_mut() {
7516 m.insert(c.head.entity_hex.clone(), r);
7517 }
7518 };
7519 if excluded.contains(&ah) || excluded.contains(&grant.member) {
7520 note(&mut why, "grant: author or member banned");
7521 continue;
7522 }
7523 let resolved: Vec<(String, u32)> = grant
7540 .role_ids
7541 .iter()
7542 .filter_map(|rid| accepted.role(rid).map(|r| (rid.clone(), r.position)))
7543 .collect();
7544 let positions: Vec<u32> = resolved.iter().map(|(_, p)| *p).collect();
7545 if resolved.is_empty() && !grant.role_ids.is_empty() {
7546 if let Some(m) = why.as_mut() {
7550 let missing: Vec<String> = grant
7551 .role_ids
7552 .iter()
7553 .filter(|rid| accepted.role(rid).is_none())
7554 .map(|rid| rid[..8.min(rid.len())].to_string())
7555 .collect();
7556 MISSING_ROLE_REFS.with(|c| {
7557 c.borrow_mut().insert(c2.head.entity_hex.clone(), missing.join("+"));
7558 });
7559 m.insert(c2.head.entity_hex.clone(), "grant: references a role not in the accepted roster");
7560 }
7561 continue;
7562 }
7563 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
7574 if c.citation.is_some() {
7575 note(&mut why, "grant: cited a grant we cannot resolve");
7576 continue;
7577 }
7578 if resolved.is_empty() {
7579 note(&mut why, "grant: UNCITED revocation — a strip must cite its authority");
7580 continue;
7581 }
7582 UNCITED_ACCEPTED.with(|n| *n.borrow_mut() += 1);
7583 }
7584 if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
7585 && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
7586 {
7587 next_heads.push(c.head.clone());
7591 if !resolved.is_empty() {
7592 let mut g = grant.clone();
7595 g.role_ids = resolved.iter().map(|(rid, _)| rid.clone()).collect();
7596 next.grants.push(g);
7597 }
7598 break;
7599 }
7600 }
7601 }
7602
7603 let converged = next.roles == accepted.roles && next.grants == accepted.grants;
7604 accepted = next;
7605 heads = next_heads;
7606 if converged {
7607 break;
7608 }
7609 }
7610 (accepted, heads)
7611}
7612
7613fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
7617 let mut changed = false;
7618 if out.name != meta.name {
7619 out.name = meta.name;
7620 changed = true;
7621 }
7622 if out.description != meta.description {
7623 out.description = meta.description;
7624 changed = true;
7625 }
7626 if out.icon != meta.icon {
7630 out.icon = meta.icon;
7631 changed = true;
7632 }
7633 if out.banner != meta.banner {
7634 out.banner = meta.banner;
7635 changed = true;
7636 }
7637 if out.meta_custom != meta.custom {
7640 out.meta_custom = meta.custom;
7641 changed = true;
7642 }
7643 if out.meta_extra != meta.extra {
7644 out.meta_extra = meta.extra;
7645 changed = true;
7646 }
7647 let relays = crate::community::cap_relays(meta.relays);
7655 if !relays.is_empty() && out.relays != relays {
7656 out.relays = relays;
7657 changed = true;
7658 }
7659 changed
7660}
7661
7662fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
7667 let deleted = meta.deleted.unwrap_or(false);
7668 if deleted {
7669 let before = out.channels.len();
7670 out.channels.retain(|c| c.id.0 != id.0);
7671 return out.channels.len() != before;
7672 }
7673 match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
7674 Some(existing) => {
7675 let mut changed = false;
7676 if existing.name != meta.name {
7677 existing.name = meta.name;
7678 changed = true;
7679 }
7680 if existing.voice != meta.voice {
7683 existing.voice = meta.voice;
7684 changed = true;
7685 }
7686 if existing.meta_custom != meta.custom {
7687 existing.meta_custom = meta.custom;
7688 changed = true;
7689 }
7690 if existing.meta_extra != meta.extra {
7691 existing.meta_extra = meta.extra;
7692 changed = true;
7693 }
7694 if !meta.private && (existing.private || existing.key.is_some()) {
7703 existing.private = false;
7704 existing.key = None;
7705 changed = true;
7706 }
7707 changed
7708 }
7709 None if !meta.private => {
7710 out.channels.push(ChannelV2 {
7713 id,
7714 name: meta.name,
7715 private: false,
7716 key: None,
7717 epoch: out.root_epoch,
7718 voice: meta.voice,
7719 meta_custom: meta.custom,
7720 meta_extra: meta.extra,
7721 });
7722 true
7723 }
7724 None => {
7725 out.channels.push(ChannelV2 {
7731 id,
7732 name: meta.name,
7733 private: true,
7734 key: None,
7735 epoch: Epoch(0),
7736 voice: meta.voice,
7737 meta_custom: meta.custom,
7738 meta_extra: meta.extra,
7739 });
7740 true
7741 }
7742 }
7743}
7744
7745pub struct RekeyFollow {
7749 pub updated: Option<CommunityV2>,
7752 pub self_removed: bool,
7755 pub dissolved: bool,
7758 pub wedged: bool,
7767}
7768
7769const MAX_ADDRESSING_ROOTS: usize = 8;
7776
7777pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
7786 let mut roots: Vec<[u8; 32]> = vec![cur_root];
7787 let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
7788 .unwrap_or_default();
7789 archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
7790 for (_, r) in archived {
7791 if !roots.contains(&r) {
7792 roots.push(r);
7793 }
7794 }
7795 roots.truncate(MAX_ADDRESSING_ROOTS);
7796 roots
7797}
7798
7799pub async fn explain_epoch_state<T: Transport + ?Sized>(
7843 transport: &T,
7844 community: &CommunityV2,
7845) -> Result<serde_json::Value, String> {
7846 let my_xonly = me_pk()?.to_bytes();
7847 let owner = community.owner()?;
7848 let owner_hex = owner.to_hex();
7849 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7850 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
7851 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
7852 let held_epoch = community.root_epoch;
7853 let held_key = community.community_root;
7854 let next = Epoch(held_epoch.0.saturating_add(1));
7855 let group = base_rekey_group_key(&held_key, community.id(), next);
7856 let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
7857 let rotations = rekey::collect_rotations(&chunks);
7858
7859 let reports: Vec<serde_json::Value> = rotations
7860 .iter()
7861 .map(|r| {
7862 let rotator_is_owner = r.rotator == owner;
7863 let rotator_authorized = rotator_is_owner
7866 || (!banned.contains(&r.rotator.to_hex())
7867 && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
7868 let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
7869 let epoch_ok = r.new_epoch.0 == next.0;
7870 let complete = r.is_complete();
7871 let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
7872 let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
7873 let owner_kept = r.rotator == owner
7878 || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
7879 let verdict = if !rotator_authorized {
7881 "REJECTED: rotator holds no BAN authority in the folded roster"
7882 } else if !scope_ok {
7883 "REJECTED: scope is not Root"
7884 } else if !epoch_ok {
7885 "REJECTED: new_epoch != held+1"
7886 } else if !complete {
7887 "WAIT: rotation incomplete (missing chunk) — never concludes removal"
7888 } else if continuity != "Extends" {
7889 "REJECTED: continuity does not extend my held root (FORK/GAP)"
7890 } else if has_my_blob {
7891 "ADOPT: authorized + complete + continuous + my blob present"
7892 } else {
7893 "REMOVED: complete authorized rotation with no blob for me"
7894 };
7895 serde_json::json!({
7896 "rotator": r.rotator.to_hex(),
7897 "rotator_is_recorded_owner": rotator_is_owner,
7898 "rotator_authorized_ban": rotator_authorized,
7899 "scope_is_root": scope_ok,
7900 "new_epoch": r.new_epoch.0,
7901 "prev_epoch": r.prev_epoch.0,
7902 "declared_chunks": r.declared_chunks,
7903 "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
7904 "is_complete": complete,
7905 "continuity_vs_held_root": continuity,
7906 "my_blob_present": has_my_blob,
7907 "owner_kept": owner_kept,
7908 "blob_count": r.blobs.len(),
7909 "severing": r.severed,
7910 "verdict": verdict,
7911 })
7912 })
7913 .collect();
7914
7915 let roster_at = crate::db::community::get_community_roles_at(&cid_hex).unwrap_or(0);
7920 let admin_count = roster.grants.iter().filter(|g| roster.is_admin(&g.member)).count();
7921 let held = serde_json::json!({
7922 "root_epoch": held_epoch.0,
7923 "roster_roles": roster.roles.len(),
7924 "roster_grants": roster.grants.len(),
7925 "roster_admins": admin_count,
7926 "roster_provenance_at": roster_at,
7930 "banlist_entries": banned.len(),
7931 "sever_marks": sever_marks(&cid_hex).keys().copied().collect::<Vec<_>>(),
7932 "control_plane_split": community.control_pk.is_some(),
7933 "private_channels": community.channels.iter().filter(|c| c.private).count(),
7934 "channels_awaiting_key": community.channels.iter().filter(|c| c.private && c.key.is_none()).count(),
7935 });
7936
7937 let summary = if rotations.is_empty() {
7940 "UP TO DATE (or the plane is unreachable): no rotation offered at held+1"
7941 } else if reports.iter().any(|r| r["verdict"].as_str().is_some_and(|v| v.starts_with("ADOPT"))) {
7942 "BEHIND: an adoptable rotation is waiting — run a sync"
7943 } else {
7944 "WEDGED: a rotation exists at held+1 and every one was refused (see verdicts)"
7945 };
7946
7947 Ok(serde_json::json!({
7948 "summary": summary,
7949 "recorded_owner": owner.to_hex(),
7950 "held": held,
7951 "held_root_epoch": held_epoch.0,
7952 "probing_next_epoch": next.0,
7953 "base_plane_pk": group.pk_hex(),
7954 "raw_chunks_parsed": chunks.len(),
7955 "rotations_found": rotations.len(),
7956 "rotations": reports,
7957 }))
7958}
7959
7960pub async fn follow_rekeys<T: Transport + ?Sized>(
7961 transport: &T,
7962 community: &CommunityV2,
7963 session: &std::sync::Arc<crate::db::Session>,
7964) -> Result<RekeyFollow, String> {
7965 crate::db::scoped(async move {
7966 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7969 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
7970 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true, wedged: false });
7971 }
7972 if is_dissolved(transport, community).await {
7978 if session.is_live() {
7979 let _ = crate::db::community::set_community_dissolved(&cid_hex);
7980 }
7981 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true, wedged: false });
7982 }
7983 let signer = crate::signer::active_signer()?;
7984 let my_pk = me_pk()?;
7985 let my_xonly = my_pk.to_bytes();
7986 let owner = community.owner()?;
7987 let owner_hex = owner.to_hex();
7988 let mut cur = community.clone();
7989 let mut changed = false;
7990 let mut wedged = false;
7992
7993 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
8005 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
8006 let me_hex = my_pk.to_hex();
8007 let cited_ok = |rot: &rekey::Rotation| -> bool {
8016 citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
8017 };
8018 let channel_rotator_ok = |rotator: &PublicKey| -> bool {
8019 if *rotator == owner {
8020 return true;
8021 }
8022 let rh = rotator.to_hex();
8023 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
8024 };
8025 let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
8030 if *rotator == owner {
8031 return true;
8032 }
8033 let rh = rotator.to_hex();
8034 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
8035 };
8036 let base_rotator_ok = |rotator: &PublicKey| -> bool {
8042 if *rotator == owner {
8043 return true;
8044 }
8045 let rh = rotator.to_hex();
8046 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
8047 };
8048 let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
8054 if *rotator == owner {
8055 return true;
8056 }
8057 let rh = rotator.to_hex();
8058 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
8059 };
8060
8061 let base_admissible = |r: &rekey::Rotation| -> bool {
8068 if r.rotator == owner {
8069 return true; }
8071 if !cited_ok(r) {
8075 return false;
8076 }
8077 let rotator_hex = r.rotator.to_hex();
8078 let has_blob = |xonly: &[u8; 32]| {
8079 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
8080 };
8081 if !has_blob(&owner.to_bytes()) {
8083 return false;
8084 }
8085 for g in &roster.grants {
8086 if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
8087 continue; }
8089 if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
8091 if let Ok(pk) = PublicKey::from_hex(&g.member) {
8092 if !has_blob(&pk.to_bytes()) {
8093 return false; }
8095 }
8096 }
8097 }
8098 true
8099 };
8100
8101 if cur.root_epoch.0 > 0 {
8122 let prior = crate::db::community::held_epoch_key(
8123 &cid_hex,
8124 crate::community::SERVER_ROOT_SCOPE_HEX,
8125 cur.root_epoch.0 - 1,
8126 )
8127 .ok()
8128 .flatten();
8129 if let Some(prior_root) = prior {
8130 let group = base_rekey_group_key(&prior_root, cur.id(), cur.root_epoch);
8131 match fetch_rekey_chunks(transport, &cur.relays, &group).await {
8132 Ok(siblings) if !siblings.is_empty() => {
8133 let held_before = cur.community_root;
8134 match advance_scope(
8135 &[(siblings, Some((Epoch(cur.root_epoch.0 - 1), prior_root)))],
8136 RekeyScope::Root,
8137 cur.id(),
8138 &base_rotator_ok,
8139 &base_rotator_outranks_me,
8140 &base_admissible,
8141 &signer,
8142 &my_xonly,
8143 cur.root_epoch,
8144 )
8145 .await
8146 {
8147 Advance::Adopt { new_key, control_pk, control_root, .. } if new_key < held_before => {
8149 crate::log_warn!(
8150 "[v2:follow {}] same-epoch fork at epoch {} — converging DOWN onto the canonical sibling",
8151 &cid_hex[..8.min(cid_hex.len())], cur.root_epoch.0
8152 );
8153 cur.community_root = new_key;
8154 cur.control_pk = control_pk.and_then(|pk| PublicKey::from_slice(&pk).ok());
8155 cur.control_root = control_root;
8156 if let Err(e) = crate::db::community::store_epoch_key(
8157 &cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, cur.root_epoch.0, &new_key,
8158 ) {
8159 crate::log_warn!("v2: converged epoch-key archive failed: {e}");
8160 }
8161 if matches!(crate::db::community::community_protocol(community.id()), Ok(Some(_))) {
8162 let _ = crate::db::community::save_community_v2(&cur);
8163 }
8164 changed = true;
8165 }
8166 _ => {}
8171 }
8172 }
8173 Ok(_) => {}
8174 Err(e) => crate::log_warn!(
8175 "[v2:follow {}] same-epoch fork check failed (will retry): {}",
8176 &cid_hex[..8.min(cid_hex.len())], e
8177 ),
8178 }
8179 }
8180 }
8181
8182 const MAX_STEPS: usize = MAX_WALK_STEPS;
8186 const CHANNEL_FAN_CONCURRENCY: usize = 8;
8189 let mut severed_hops: Vec<u64> = Vec::new();
8192 for _ in 0..MAX_STEPS {
8193 let mut advanced = false;
8194
8195 let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
8200
8201 let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
8204 let fan: Vec<(ChannelId, Option<[u8; 32]>, Epoch, Epoch)> = channel_ids
8210 .iter()
8211 .filter_map(|cid| {
8212 cur.channel(cid).map(|ch| {
8213 (*cid, ch.key, ch.epoch, Epoch(ch.epoch.0.saturating_add(1)))
8214 })
8215 })
8216 .collect();
8217 let mut prefetched: std::collections::HashMap<([u8; 32], usize), Vec<rekey::RekeyChunk>> =
8218 std::collections::HashMap::new();
8219 {
8220 use futures_util::stream::StreamExt;
8221 let jobs: Vec<(ChannelId, usize, GroupKey, Epoch)> = fan
8224 .iter()
8225 .flat_map(|(cid, _, _, next)| {
8226 addressing_roots.iter().enumerate().map(move |(ri, root)| {
8227 (*cid, ri, channel_rekey_group_key(root, cid, *next), *next)
8228 })
8229 })
8230 .collect();
8231 let relays = &cur.relays;
8232 let mut results = futures_util::stream::iter(jobs)
8233 .map(|(cid, ri, group, next)| async move {
8234 (cid, ri, next, fetch_rekey_chunks(transport, relays, &group).await)
8235 })
8236 .buffer_unordered(CHANNEL_FAN_CONCURRENCY);
8237 while let Some((cid, ri, next, out)) = results.next().await {
8238 let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
8239 match out {
8240 Ok(chunks) => {
8241 if chunks.is_empty() {
8242 continue;
8243 }
8244 crate::log_debug!(
8245 "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
8246 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
8247 );
8248 prefetched.insert((cid.0, ri), chunks);
8249 }
8250 Err(e) => {
8251 crate::log_warn!(
8252 "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
8253 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
8254 );
8255 return Err(e);
8256 }
8257 }
8258 }
8259 }
8260
8261 for (cid, held_key, held_epoch, next) in fan {
8262 let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
8263 let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
8264 for ri in 0..addressing_roots.len() {
8265 if let Some(chunks) = prefetched.remove(&(cid.0, ri)) {
8266 batches.push((chunks, held_key.map(|k| (held_epoch, k))));
8267 }
8268 }
8269 match advance_scope(&batches, RekeyScope::Channel(cid), cur.id(), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
8276 Advance::Adopt { new_key, .. } => {
8277 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
8278 ch.key = Some(new_key);
8279 ch.epoch = next;
8280 }
8281 crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
8282 if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&cid.0), next.0, &new_key) {
8287 crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
8288 }
8289 advanced = true;
8290 changed = true;
8291 }
8292 Advance::Removed => {
8293 match held_key {
8294 Some(_) => {
8296 cur.channels.retain(|c| c.id.0 != cid.0);
8297 }
8298 None => {
8303 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
8304 ch.epoch = next;
8305 }
8306 }
8307 }
8308 advanced = true;
8309 changed = true;
8310 }
8311 Advance::Stay => {}
8312 Advance::StayWedged => {
8313 wedged = true;
8317 }
8318 }
8319 }
8320
8321 {
8325 let held_epoch = cur.root_epoch;
8326 let held_key = cur.community_root;
8327 let next = Epoch(held_epoch.0.saturating_add(1));
8328 let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
8329 let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
8330 let batches = vec![(chunks, Some((held_epoch, held_key)))];
8331 match advance_scope(&batches, RekeyScope::Root, cur.id(), &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
8332 Advance::Adopt { new_key, control_pk, control_root, severed, rotator } => {
8333 record_epoch_minter(&cid_hex, next.0, &rotator.to_hex());
8338 if severed {
8343 severed_hops.push(next.0);
8344 }
8345 cur.community_root = new_key;
8346 cur.root_epoch = next;
8347 if cur.control_pk.is_some() && control_pk.is_none() {
8359 crate::log_warn!(
8360 "[v2:follow {}] rotation to epoch {} carried a LEGACY base blob — this community's control plane reverts to the member-writable address",
8361 &cid_hex[..8.min(cid_hex.len())], next.0
8362 );
8363 }
8364 cur.control_pk = control_pk.and_then(|pk| PublicKey::from_slice(&pk).ok());
8365 cur.control_root = control_root;
8366 if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
8370 crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
8371 }
8372 match crate::db::community::community_protocol(community.id()) {
8390 Ok(Some(_)) => {
8391 if let Err(e) = crate::db::community::save_community_v2(&cur) {
8392 crate::log_warn!("v2: hop checkpoint failed at epoch {} (the walk will redo it): {e}", next.0);
8393 }
8394 }
8395 Ok(None) => return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged }),
8397 Err(e) => crate::log_warn!("v2: hop checkpoint skipped at epoch {}: {e}", next.0),
8398 }
8399 advanced = true;
8400 changed = true;
8401 }
8402 Advance::Removed => {
8403 return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false, wedged: false });
8404 }
8405 Advance::Stay => {}
8406 Advance::StayWedged => {
8407 wedged = true;
8411 }
8412 }
8413 }
8414
8415 if !advanced {
8416 break;
8417 }
8418 }
8419
8420 if wedged {
8421 crate::log_warn!(
8425 "[v2:follow {}] a complete rotation delivered a key blob this client cannot open — parked at epoch {} (never treated as a removal)",
8426 &cid_hex[..8.min(cid_hex.len())], cur.root_epoch.0
8427 );
8428 crate::emit_event(
8429 "community_rekey_wedged",
8430 &serde_json::json!({ "community_id": cid_hex, "held_epoch": cur.root_epoch.0 }),
8431 );
8432 }
8433 if !changed {
8434 retry_pending_severance(transport, &cur).await;
8439 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged });
8440 }
8441 if crate::db::community::community_protocol(community.id())?.is_none() {
8444 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged });
8445 }
8446 crate::db::community::save_community_v2(&cur)?;
8447 for hop in &severed_hops {
8452 add_sever_mark(&cid_hex, *hop, now_ms() / 1000);
8453 }
8454 if !severed_hops.is_empty() {
8455 if let Err(e) = sever_own_links(transport, &cur).await {
8460 crate::log_warn!("[Sever] own-link revocation on adopt failed (retries next follow): {e}");
8461 }
8462 }
8463 let _ = refresh_public_links(transport, &cur).await;
8470 retry_pending_severance(transport, &cur).await;
8473 Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false, wedged })
8474 })
8475 .await
8476}
8477
8478enum Advance {
8481 Adopt { new_key: [u8; 32], control_pk: Option<[u8; 32]>, control_root: Option<[u8; 32]>, severed: bool, rotator: PublicKey },
8488 Removed,
8490 Stay,
8492 StayWedged,
8496}
8497
8498async fn fetch_rekey_chunks<T: Transport + ?Sized>(
8500 transport: &T,
8501 relays: &[String],
8502 group: &GroupKey,
8503) -> Result<Vec<rekey::RekeyChunk>, String> {
8504 const REKEY_PAGE: usize = 200;
8512 const REKEY_MAX_PAGES: usize = 6;
8513 let mut out = Vec::new();
8514 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
8515 let mut until: Option<u64> = None;
8516 let mut oldest: Option<u64> = None;
8517 for _ in 0..REKEY_MAX_PAGES {
8518 let query = Query {
8519 kinds: vec![stream::KIND_WRAP],
8520 authors: vec![group.pk_hex()],
8521 until,
8522 limit: Some(REKEY_PAGE),
8523 ..Default::default()
8524 };
8525 let page = transport.fetch_plane(group.keys(), &query, relays).await?;
8530 let page_complete = page.is_complete();
8535 let wraps = page.events;
8536 let mut fresh = 0usize;
8537 for w in &wraps {
8538 if !seen.insert(w.id) {
8539 continue;
8540 }
8541 fresh += 1;
8542 let at = w.created_at.as_secs();
8543 if oldest.is_none_or(|o| at < o) {
8544 oldest = Some(at);
8545 }
8546 if let Ok(opened) = stream::open_wrap(w, group) {
8547 if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
8548 out.push(chunk);
8549 }
8550 }
8551 }
8552 if fresh == 0 || (wraps.len() < REKEY_PAGE && page_complete) {
8555 break;
8556 }
8557 match oldest {
8558 Some(o) if o > 0 => until = Some(o),
8559 _ => break,
8560 }
8561 }
8562 Ok(out)
8563}
8564
8565async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
8579 batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
8580 scope: RekeyScope,
8581 community_id: &crate::community::CommunityId,
8582 rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
8583 rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
8584 admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
8585 signer: &S,
8586 my_xonly: &[u8; 32],
8587 next_epoch: Epoch,
8588) -> Advance {
8589 let mut winners: Vec<rekey::BaseKeyDelivery> = Vec::new();
8590 let mut winner_severed: Vec<bool> = Vec::new();
8594 let mut winner_rotator: Vec<PublicKey> = Vec::new();
8597 let mut saw_complete_candidate = false;
8598 let mut saw_nonempty_candidate = false;
8600 let mut saw_outranking_candidate = false;
8601 let mut my_blob_unopenable = false;
8602 let keyed = batches.iter().any(|(_, held)| held.is_some());
8603 for (chunks, held) in batches {
8604 let rotations = rekey::collect_rotations(chunks);
8605 for r in &rotations {
8606 if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 {
8607 continue;
8608 }
8609 if let Some((held_epoch, held_key)) = held {
8610 if r.continuity(*held_epoch, held_key) != Continuity::Extends {
8611 continue;
8612 }
8613 }
8614 if !admissible(r) {
8620 continue;
8621 }
8622 if r.is_complete() {
8638 saw_complete_candidate = true;
8639 saw_nonempty_candidate |= !r.blobs.is_empty();
8640 saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
8641 }
8642 let mut mine = rekey::find_my_blobs(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch).peekable();
8647 let had_candidate = mine.peek().is_some();
8648 let mut opened_one = false;
8649 for blob in mine {
8650 let opened = match scope {
8651 RekeyScope::Root => rekey::open_base_blob(signer, &r.rotator, community_id, r.new_epoch, blob).await,
8653 RekeyScope::Channel(_) => rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob)
8654 .await
8655 .map(|k| rekey::BaseKeyDelivery { new_root: k, control_pk: None, control_root: None }),
8656 };
8657 if let Ok(d) = opened {
8658 winners.push(d);
8659 winner_severed.push(scope.id32() == RekeyScope::Root.id32() && r.severed);
8660 winner_rotator.push(r.rotator);
8661 opened_one = true;
8662 break;
8663 }
8664 }
8665 if had_candidate && !opened_one {
8670 my_blob_unopenable = true;
8671 }
8672 }
8673 }
8674 if !winners.is_empty() {
8675 let keys: Vec<[u8; 32]> = winners.iter().map(|d| d.new_root).collect();
8684 let idx = rekey::lowest_key_winner(&keys).expect("winners is non-empty");
8685 let w = &winners[idx];
8686 return Advance::Adopt {
8687 new_key: w.new_root,
8688 control_pk: w.control_pk,
8689 control_root: w.control_root,
8690 severed: winner_severed[idx],
8691 rotator: winner_rotator[idx],
8692 };
8693 }
8694 if saw_complete_candidate && !saw_nonempty_candidate {
8701 return Advance::Stay;
8702 }
8703 if saw_complete_candidate && !my_blob_unopenable && (!keyed || saw_outranking_candidate) {
8704 Advance::Removed
8705 } else if my_blob_unopenable {
8706 Advance::StayWedged
8707 } else {
8708 Advance::Stay
8709 }
8710}
8711
8712#[derive(Debug, serde::Serialize)]
8716pub struct ChannelPins {
8717 pub pins: Vec<super::pins::VerifiedPin>,
8719 pub sealed: bool,
8723 pub version: u64,
8725}
8726
8727fn channel_conv_key_at(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Option<[u8; 32]> {
8731 let ikm = channel_conv_ikm(community, ch, epoch).ok()?;
8732 if ch.private && ikm == community.community_root {
8735 return None;
8736 }
8737 let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
8738 group.conv_key().as_bytes().try_into().ok()
8739}
8740
8741pub fn read_channel_pins(community: &CommunityV2, channel_id: &ChannelId) -> Result<ChannelPins, String> {
8745 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
8746 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8747 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
8748 let Some((content, version)) = crate::db::community::get_community_pins(&cid_hex, &ch_hex)? else {
8749 return Ok(ChannelPins { pins: Vec::new(), sealed: false, version: 0 });
8750 };
8751 let read = super::pins::read_pin_list(&content, |epoch| channel_conv_key_at(community, ch, epoch));
8752 let pins = read
8753 .entries
8754 .iter()
8755 .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
8756 .collect();
8757 Ok(ChannelPins { pins, sealed: read.sealed, version: version.max(0) as u64 })
8758}
8759
8760async fn publish_pin_list<T: Transport + ?Sized>(
8764 transport: &T,
8765 community: &CommunityV2,
8766 ch: &ChannelV2,
8767 entries: &[super::pins::PinEntry],
8768) -> Result<(), String> {
8769 crate::db::scoped(async move {
8770 let content = if ch.private {
8771 let key = ch.key.ok_or("this private channel's key has not arrived yet")?;
8772 let group = channel_group_key(&key, &ch.id, ch.epoch);
8773 super::pins::serialize_sealed_pin_list(entries, group.conv_key(), ch.epoch.0)?
8774 } else {
8775 super::pins::serialize_public_pin_list(entries)?
8776 };
8777 let eid = super::derive::pins_locator(community.id(), &ch.id);
8778 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8779 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
8780 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
8781 let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
8783 Some((v, _)) => v + 1,
8784 None => 1,
8785 };
8786 crate::log_info!("[pins] publishing v{} with {} entries for channel {}", version, entries.len(), &ch_hex[..12]);
8787 publish_control_edition(transport, community, vsk::PINS, &eid, &content).await?;
8788 let _ = crate::db::community::set_community_pins(&cid_hex, &ch_hex, &content, version as i64);
8789 crate::emit_event(
8790 "community_pins_updated",
8791 &serde_json::json!({ "community_id": cid_hex, "channel_id": ch_hex }),
8792 );
8793 if let Ok(me) = me_pk() {
8794 use nostr_sdk::prelude::ToBech32;
8795 let me_npub = me.to_bech32().unwrap_or_else(|_| me.to_hex());
8796 note_pins_modified(&ch_hex, version, &me_npub, now_ms() / 1000).await;
8797 }
8798 Ok(())
8799 })
8800 .await
8801}
8802
8803async fn note_pins_modified(channel_hex: &str, version: u64, actor_npub: &str, at_secs: u64) {
8808 let event_id = format!("pins-mod-{}-v{}", &channel_hex[..16], version);
8809 let inserted = crate::db::events::save_system_event_at(
8810 &event_id,
8811 channel_hex,
8812 crate::stored_event::SystemEventType::PinsModified,
8813 actor_npub,
8814 None,
8815 at_secs,
8816 None,
8817 None,
8818 )
8819 .await
8820 .unwrap_or(false);
8821 if inserted {
8822 crate::emit_event(
8823 "system_event",
8824 &serde_json::json!({
8825 "conversation_id": channel_hex,
8826 "event_id": event_id,
8827 "event_type": crate::stored_event::SystemEventType::PinsModified.as_u8(),
8828 "member_pubkey": actor_npub,
8829 }),
8830 );
8831 }
8832}
8833
8834fn writable_pin_entries(community: &CommunityV2, channel_id: &ChannelId) -> Result<Vec<super::pins::PinEntry>, String> {
8838 let current = read_channel_pins(community, channel_id)?;
8839 if current.sealed {
8840 return Err("this channel's pins are sealed under a key you don't hold; pinning would erase them".to_string());
8841 }
8842 Ok(current.pins.into_iter().map(|p| p.entry).collect())
8843}
8844
8845pub async fn pin_message<T: Transport + ?Sized>(
8850 transport: &T,
8851 community: &CommunityV2,
8852 channel_id: &ChannelId,
8853 rumor_id_hex: &str,
8854) -> Result<(), String> {
8855 crate::db::scoped(async move {
8856 let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
8857 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
8858
8859 let mut entries = writable_pin_entries(community, channel_id)?;
8860 if entries.len() >= super::pins::PIN_MAX_ENTRIES {
8861 return Err(format!("this channel already holds {} pins; unpin one first", super::pins::PIN_MAX_ENTRIES));
8862 }
8863 if entries
8865 .iter()
8866 .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
8867 .any(|v| v.rumor_id == rumor_id_hex)
8868 {
8869 return Ok(());
8870 }
8871
8872 let (wrap_id, _tags) = crate::db::events::get_event_wrap_context(rumor_id_hex)?
8873 .ok_or("message not found in this device's history")?;
8874 let wrap_id = wrap_id.ok_or("this message's original wrap id was not recorded")?;
8875
8876 let wraps = transport
8879 .fetch(
8880 &Query {
8881 ids: vec![wrap_id.clone()],
8882 kinds: vec![super::stream::KIND_WRAP],
8883 limit: Some(1),
8884 evidence: crate::community::transport::Evidence::Full,
8885 ..Default::default()
8886 },
8887 &community.relays,
8888 )
8889 .await?;
8890 let wrap = wraps
8891 .iter()
8892 .find(|w| w.id.to_hex() == wrap_id)
8893 .ok_or("the message's wrap is no longer served by this community's relays")?;
8894
8895 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8901 let mut coords: Vec<(u64, [u8; 32])> = Vec::new();
8902 if ch.private {
8903 if let Some(k) = ch.key {
8904 coords.push((ch.epoch.0, k));
8905 }
8906 let held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
8907 coords.extend(held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (ep.0, k)));
8908 } else {
8909 coords.push((community.root_epoch.0, community.community_root));
8910 let held = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
8911 coords.extend(held.into_iter().map(|(ep, k)| (ep.0, k)));
8912 }
8913 coords.dedup();
8914
8915 let mut found: Option<(super::stream::OpenedStream, [u8; 32])> = None;
8916 for (epoch, ikm) in coords {
8917 let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
8918 if let Ok(super::chat::ChatEvent::Message { opened, .. }) =
8919 super::chat::open_chat_event(wrap, &group, channel_id, Epoch(epoch))
8920 {
8921 if opened.rumor_id.to_hex() == rumor_id_hex {
8922 let conv: [u8; 32] = group
8923 .conv_key()
8924 .as_bytes()
8925 .try_into()
8926 .map_err(|_| "conversation key size".to_string())?;
8927 found = Some((opened, conv));
8928 break;
8929 }
8930 }
8931 }
8932 let Some((opened, conv_key)) = found else {
8933 return Err("that message is from a key epoch this device no longer holds".to_string());
8934 };
8935
8936 let entry = super::pins::build_pin_entry(&opened, &conv_key, &ch_hex).map_err(|e| match e {
8937 super::pins::PinBuildFailure::NotEncrypted => "this message's seal form cannot be pinned".to_string(),
8938 super::pins::PinBuildFailure::BadPayload => "that message is from a key epoch this device no longer holds".to_string(),
8939 super::pins::PinBuildFailure::Unverifiable => "this message's proof did not verify".to_string(),
8940 })?;
8941 entries.push(entry);
8942
8943 publish_pin_list(transport, community, &ch, &entries).await
8944 })
8945 .await
8946}
8947
8948fn channel_conv_ikm(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Result<[u8; 32], String> {
8951 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8952 if ch.private {
8953 if ch.epoch.0 == epoch {
8954 return ch.key.ok_or("this private channel's key has not arrived yet".to_string());
8955 }
8956 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
8957 crate::db::community::held_epoch_keys(&cid_hex, &ch_hex)
8958 .unwrap_or_default()
8959 .into_iter()
8960 .find(|(ep, _)| ep.0 == epoch)
8961 .map(|(_, k)| k)
8962 .ok_or("that message is from a key epoch this device no longer holds".to_string())
8963 } else if community.root_epoch.0 == epoch {
8964 Ok(community.community_root)
8965 } else {
8966 crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
8967 .unwrap_or_default()
8968 .into_iter()
8969 .find(|(ep, _)| ep.0 == epoch)
8970 .map(|(_, k)| k)
8971 .ok_or("that message is from a root epoch this device no longer holds".to_string())
8972 }
8973}
8974
8975pub async fn unpin_message<T: Transport + ?Sized>(
8977 transport: &T,
8978 community: &CommunityV2,
8979 channel_id: &ChannelId,
8980 rumor_id_hex: &str,
8981) -> Result<(), String> {
8982 let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
8983 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
8984 let entries = writable_pin_entries(community, channel_id)?;
8985 let kept: Vec<super::pins::PinEntry> = entries
8986 .into_iter()
8987 .filter(|e| {
8988 super::pins::verify_pin_entry(e, &ch_hex)
8989 .map(|v| v.rumor_id != rumor_id_hex)
8990 .unwrap_or(true)
8993 })
8994 .collect();
8995 publish_pin_list(transport, community, &ch, &kept).await
8996}
8997
8998pub(crate) fn spawn_pin_duty(channel_hex: &str, target_rumor_hex: &str, edit: Option<super::stream::OpenedStream>) {
9002 let channel_hex = channel_hex.to_string();
9003 let target = target_rumor_hex.to_string();
9004 crate::db::spawn_bound(async move {
9005 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
9006 let _ = run_pin_duty(&transport, &channel_hex, &target, edit).await;
9007 });
9008}
9009
9010async fn run_pin_duty<T: Transport + ?Sized>(
9017 transport: &T,
9018 channel_hex: &str,
9019 target: &str,
9020 edit: Option<super::stream::OpenedStream>,
9021) -> Result<(), String> {
9022 use crate::community::roles::Permissions;
9023 let Some(cid_hex) = crate::db::community::community_id_for_channel(channel_hex)? else {
9024 return Ok(());
9025 };
9026 let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
9027 let Some(community) = crate::db::community::load_community_v2(&cid)? else {
9028 return Ok(());
9029 };
9030 let channel_id = ChannelId(crate::simd::hex::hex_to_bytes_32(channel_hex));
9031
9032 let read = read_channel_pins(&community, &channel_id)?;
9034 if read.sealed {
9035 return Ok(());
9036 }
9037 let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
9038 return Ok(());
9039 };
9040 if let Some(ed) = &edit {
9041 if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
9043 return Ok(());
9044 }
9045 }
9046 let me = me_pk()?;
9047 let me_hex = me.to_hex();
9048 let owner_hex = community.owner()?.to_hex();
9049 let roster = crate::db::community::get_community_roles(&cid_hex)?;
9050 if !roster.is_authorized(&me_hex, Some(&owner_hex), Permissions::PIN_MESSAGES) {
9051 return Ok(());
9052 }
9053
9054 if hit.author != me_hex {
9055 let mut h: u32 = 0;
9056 for b in me_hex.bytes().chain(target.bytes()) {
9057 h = h.wrapping_mul(31).wrapping_add(u32::from(b));
9058 }
9059 tokio::time::sleep(std::time::Duration::from_secs(5 + u64::from(h % 21))).await;
9060 }
9061
9062 let read = read_channel_pins(&community, &channel_id)?;
9064 if read.sealed {
9065 return Ok(());
9066 }
9067 let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
9068 return Ok(());
9069 };
9070 let ch = community.channel(&channel_id).ok_or("no such channel")?.clone();
9071
9072 let entries: Vec<super::pins::PinEntry> = match &edit {
9073 None => read
9075 .pins
9076 .iter()
9077 .filter(|p| p.rumor_id != target)
9078 .map(|p| p.entry.clone())
9079 .collect(),
9080 Some(ed) => {
9082 if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
9083 return Ok(());
9084 }
9085 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
9086 let mut bundle = None;
9089 let mut epochs: Vec<u64> = vec![if ch.private { ch.epoch.0 } else { community.root_epoch.0 }];
9090 let scope = if ch.private { ch_hex.clone() } else { crate::community::SERVER_ROOT_SCOPE_HEX.to_string() };
9091 epochs.extend(
9092 crate::db::community::held_epoch_keys(&cid_hex, &scope)
9093 .unwrap_or_default()
9094 .into_iter()
9095 .map(|(ep, _)| ep.0),
9096 );
9097 epochs.dedup();
9098 for epoch in epochs {
9099 let Some(conv) = channel_conv_key_at(&community, &ch, epoch) else { continue };
9100 if let Ok(b) = super::pins::build_pin_edit_bundle(ed, &conv, &hit.author, target, &ch_hex) {
9101 bundle = Some(b);
9102 break;
9103 }
9104 }
9105 let Some(bundle) = bundle else { return Ok(()) };
9106 read.pins
9107 .iter()
9108 .map(|p| {
9109 let mut entry = p.entry.clone();
9110 if p.rumor_id == target {
9111 entry.edit = Some(bundle.clone());
9112 }
9113 entry
9114 })
9115 .collect()
9116 }
9117 };
9118
9119 crate::log_info!(
9120 "[pins] duty {} for target {} in channel {}",
9121 if edit.is_some() { "edit-refresh" } else { "omission" },
9122 &target[..12],
9123 &channel_hex[..12]
9124 );
9125 publish_pin_list(transport, &community, &ch, &entries).await
9126}
9127
9128pub async fn upgrade_admin_role_pin_bit<T: Transport + ?Sized>(
9133 transport: &T,
9134 community: &CommunityV2,
9135) -> Result<bool, String> {
9136 use crate::community::roles::{Permissions, RoleScope};
9137 let my_pk = me_pk()?;
9138 if community.owner()? != my_pk {
9139 return Ok(false);
9140 }
9141 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9142 let roles = crate::db::community::get_community_roles(&cid_hex)?;
9143 let Some(role) = roles.roles.iter().find(|r| {
9144 matches!(r.scope, RoleScope::Server)
9145 && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK)
9146 && !r.permissions.contains(Permissions::PIN_MESSAGES)
9147 }) else {
9148 return Ok(false);
9149 };
9150 let mut widened = role.clone();
9151 widened.permissions.0 |= Permissions::PIN_MESSAGES;
9152 set_role(transport, community, &widened).await?;
9153 Ok(true)
9154}
9155
9156#[cfg(test)]
9157mod tests {
9158 use super::super::derive::control_group_key;
9161 use crate::ClientRelayExt;
9162 use nostr_sdk::prelude::FinalizeEvent;
9163 use super::super::super::transport::memory::MemoryRelay;
9164 use super::*;
9165 use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
9166
9167 fn account_name(n: u32) -> String {
9169 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
9170 let mut acct = String::from("npub1");
9171 let mut v = n as usize;
9172 for _ in 0..58 {
9173 acct.push(B[v % 32] as char);
9174 v = v / 32 + 7;
9175 }
9176 acct
9177 }
9178
9179 struct Actor {
9181 keys: Keys,
9182 account: String,
9183 }
9184
9185 struct TestBed {
9191 _tmp: tempfile::TempDir,
9192 _guard: std::sync::MutexGuard<'static, ()>,
9193 relay: MemoryRelay,
9194 relays: Vec<String>,
9195 }
9196
9197 impl TestBed {
9198 fn new() -> (TestBed, Actor, Actor) {
9199 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
9200 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
9201 crate::db::close_database();
9202 crate::db::clear_id_caches();
9203 let tmp = tempfile::tempdir().unwrap();
9204 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
9205
9206 let mk = || {
9207 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9208 let account = account_name(n);
9209 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
9210 crate::db::set_current_account(account.clone()).unwrap();
9211 crate::db::init_database(&account).unwrap();
9212 Actor { keys: Keys::generate(), account }
9213 };
9214 let owner = mk();
9215 let member = mk();
9216 let _ = crate::state::take_nostr_client();
9217 let bed = TestBed {
9218 _tmp: tmp,
9219 _guard: guard,
9220 relay: MemoryRelay::new(),
9221 relays: vec!["wss://r".to_string()],
9222 };
9223 (bed, owner, member)
9224 }
9225
9226 fn swap_to(&self, actor: &Actor) {
9231 crate::db::close_database();
9232 crate::db::set_current_account(actor.account.clone()).unwrap();
9233 crate::db::init_database(&actor.account).unwrap();
9234 crate::db::clear_id_caches();
9235 crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
9236 crate::state::set_my_public_key(actor.keys.public_key());
9237 }
9238 }
9239
9240 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
9242 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
9243 crate::db::close_database();
9244 crate::db::clear_id_caches();
9245 let tmp = tempfile::tempdir().unwrap();
9246 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
9247 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9248 let acct = account_name(n);
9249 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
9250 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
9251 crate::db::set_current_account(acct.clone()).unwrap();
9252 crate::db::init_database(&acct).unwrap();
9253 let _ = crate::state::take_nostr_client();
9254 let owner = Keys::generate();
9255 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
9256 crate::state::set_my_public_key(owner.public_key());
9257 (tmp, guard, owner)
9258 }
9259
9260 struct SwapMidFetch<'a> {
9267 inner: &'a MemoryRelay,
9268 to: String,
9269 armed: std::sync::atomic::AtomicBool,
9270 }
9271
9272 impl<'a> SwapMidFetch<'a> {
9273 fn arm(inner: &'a MemoryRelay) -> Self {
9274 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(88_000);
9275 let to = account_name(N.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
9276 std::fs::create_dir_all(crate::db::shared_test_data_dir().join(&to)).unwrap();
9277 Self { inner, to, armed: std::sync::atomic::AtomicBool::new(true) }
9278 }
9279 }
9280
9281 #[async_trait::async_trait]
9282 impl Transport for SwapMidFetch<'_> {
9283 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
9284 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
9285 self.inner.publish(e, r).await
9286 }
9287 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
9288 self.inner.publish_durable(e, r).await
9289 }
9290 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
9291 let out = self.inner.fetch(q, r).await;
9292 if self.armed.swap(false, std::sync::atomic::Ordering::SeqCst) {
9293 crate::db::set_current_account(self.to.clone()).unwrap();
9294 crate::db::init_database(&self.to).unwrap();
9295 }
9296 out
9297 }
9298 }
9299
9300 struct SwapMidPublish {
9303 inner: MemoryRelay,
9304 }
9305 #[async_trait::async_trait]
9306 impl Transport for SwapMidPublish {
9307 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
9308 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
9309 self.inner.publish(e, r).await
9310 }
9311 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
9312 let out = self.inner.publish_durable(e, r).await;
9313 crate::db::close_database();
9314 out
9315 }
9316 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
9317 self.inner.fetch(q, r).await
9318 }
9319 }
9320
9321 struct FixedFetch {
9326 events: Vec<Event>,
9327 }
9328 #[async_trait::async_trait]
9329 impl Transport for FixedFetch {
9330 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
9331 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9332 Ok(())
9333 }
9334 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9335 Ok(())
9336 }
9337 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
9338 Ok(self.events.clone())
9339 }
9340 }
9341
9342 async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
9345 let q = Query {
9346 kinds: vec![stream::KIND_WRAP],
9347 p_tags: vec![me.to_hex()],
9348 k_tags: vec!["3313".to_string()],
9349 ..Default::default()
9350 };
9351 relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
9352 }
9353
9354 #[tokio::test]
9355 async fn create_persists_and_reloads_a_v2_community() {
9356 let (_tmp, _guard, owner) = init_test_db();
9357 let relay = MemoryRelay::new();
9358 let relays = vec!["wss://r".to_string()];
9359
9360 let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
9361 assert!(created.identity.verify());
9362 assert_eq!(created.owner().unwrap(), owner.public_key());
9363 assert_eq!(created.channels.len(), 1);
9364
9365 assert_eq!(
9367 crate::db::community::community_protocol(created.id()).unwrap(),
9368 Some(crate::community::ConcordProtocol::V2)
9369 );
9370 let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
9371 assert_eq!(loaded.name, "Vectorville");
9372 assert_eq!(loaded.community_root, created.community_root);
9373 assert_eq!(loaded.identity, created.identity);
9374 assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
9375 assert!(!loaded.channels[0].private);
9376
9377 assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
9379 }
9380
9381 #[tokio::test]
9382 async fn owner_sends_and_reads_back_a_message() {
9383 let (_tmp, _guard, _owner) = init_test_db();
9384 let relay = MemoryRelay::new();
9385 let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
9386 let general = community.channels[0].id;
9387
9388 let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
9389 let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
9390 assert_ne!(id1, id2);
9391
9392 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
9393 let texts: Vec<String> = page
9394 .iter()
9395 .filter_map(|f| match &f.event {
9396 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
9397 _ => None,
9398 })
9399 .collect();
9400 assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
9401 }
9402
9403 #[test]
9404 fn send_stamps_never_repeat_within_a_process() {
9405 let stamps: Vec<u64> = (0..5_000).map(|_| next_send_ms()).collect();
9413 for pair in stamps.windows(2) {
9414 assert!(pair[1] > pair[0], "stamps must strictly increase: {} then {}", pair[0], pair[1]);
9415 }
9416 }
9417
9418 #[tokio::test]
9419 async fn same_millisecond_messages_order_by_rumor_id_not_relay_order() {
9420 let (_tmp, _guard, _owner) = init_test_db();
9426 let relay = MemoryRelay::new();
9427 let community = create_community(&relay, "Ties", vec!["wss://r".into()], None).await.unwrap();
9428 let general = community.channels[0].id;
9429
9430 let at = now_ms();
9431 for i in 0..6 {
9432 send_chat_message_at(&relay, &community, &general, &format!("tied {i}"), None, &[], vec![], at)
9433 .await
9434 .unwrap();
9435 }
9436 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
9437 let msgs: Vec<(Vec<u8>, String)> = page
9438 .iter()
9439 .filter_map(|f| match &f.event {
9440 ChatEvent::Message { .. } => {
9441 let o = f.event.opened();
9442 Some((o.rumor_id.as_bytes().to_vec(), o.rumor.content.clone()))
9443 }
9444 _ => None,
9445 })
9446 .collect();
9447 assert_eq!(msgs.len(), 6, "all six tied messages read back");
9448 let got: Vec<String> = msgs.iter().map(|(_, c)| c.clone()).collect();
9449 let mut by_id = msgs.clone();
9450 by_id.sort_by(|a, b| a.0.cmp(&b.0));
9451 let expected: Vec<String> = by_id.into_iter().map(|(_, c)| c).collect();
9452 assert_eq!(got, expected, "ties resolve by rumor id, never by the relay's serving order");
9453 }
9454
9455 #[tokio::test]
9456 async fn a_rapid_burst_carries_strictly_increasing_stamps() {
9457 let (_tmp, _guard, _owner) = init_test_db();
9462 let relay = MemoryRelay::new();
9463 let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
9464 let general = community.channels[0].id;
9465 for i in 0..8 {
9466 send_message(&relay, &community, &general, &format!("msg {i}")).await.unwrap();
9467 }
9468 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
9469 let stamps: Vec<u64> = page
9470 .iter()
9471 .filter_map(|f| match &f.event {
9472 ChatEvent::Message { .. } => Some(f.event.opened().at_ms),
9473 _ => None,
9474 })
9475 .collect();
9476 assert_eq!(stamps.len(), 8);
9477 for pair in stamps.windows(2) {
9478 assert!(pair[1] > pair[0], "a sender's own stamps must strictly increase, got {pair:?}");
9479 }
9480 }
9481
9482 #[tokio::test]
9483 async fn a_second_member_reads_the_public_channel_from_the_root() {
9484 let (_tmp, _guard, _owner) = init_test_db();
9488 let relay = MemoryRelay::new();
9489 let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
9490 let general = community.channels[0].id;
9491 send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
9492
9493 let member_view = community.clone();
9495 let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
9496 assert_eq!(page.len(), 1);
9497 assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
9498 assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
9499 }
9500
9501 async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
9504 fetch_channel(relay, community, channel, 100)
9505 .await
9506 .unwrap()
9507 .iter()
9508 .filter_map(|f| match &f.event {
9509 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
9510 _ => None,
9511 })
9512 .collect()
9513 }
9514
9515 async fn all_texts_in<T: crate::community::transport::Transport + ?Sized>(
9519 relay: &T,
9520 community: &CommunityV2,
9521 channel: &ChannelId,
9522 ) -> Vec<String> {
9523 let from_now = now_ms() / 1000 + 3600;
9524 fetch_channel_history(
9525 relay,
9526 community,
9527 channel,
9528 100,
9529 8,
9530 None,
9531 Some(from_now),
9532 crate::community::transport::Evidence::Quorum,
9533 |_| true,
9534 )
9535 .await
9536 .unwrap()
9537 .iter()
9538 .filter_map(|f| match &f.event {
9539 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
9540 _ => None,
9541 })
9542 .collect()
9543 }
9544
9545 #[tokio::test]
9546 async fn direct_invite_full_loop_owner_and_member_converse() {
9547 let (bed, owner, member) = TestBed::new();
9548
9549 bed.swap_to(&owner);
9551 let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
9552 let general = community.channels[0].id;
9553 send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
9554 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
9555
9556 bed.swap_to(&member);
9558 assert!(
9559 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
9560 "the member does not hold the community before joining"
9561 );
9562 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9563 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
9564 assert_eq!(joined.id().0, community.id().0, "joined the same community");
9565 assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
9566 assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
9567
9568 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
9570 send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
9571
9572 bed.swap_to(&owner);
9574 assert_eq!(
9575 texts_in(&bed.relay, &community, &general).await,
9576 vec!["owner: welcome!", "member: thanks for the invite"],
9577 "both actors' messages interleave in ms order on the shared channel"
9578 );
9579
9580 let members = memberlist(&bed.relay, &community).await.unwrap();
9582 assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
9583 assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
9584 assert_eq!(members.len(), 2);
9585 }
9586
9587 #[tokio::test]
9591 async fn a_banned_member_is_refused_at_join_time() {
9592 let (bed, owner, member) = TestBed::new();
9593
9594 bed.swap_to(&owner);
9595 let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
9596 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
9597 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
9598
9599 bed.swap_to(&member);
9600 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9601 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
9602 assert!(err.contains("banned"), "refusal names the reason: {err}");
9603 assert!(
9604 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
9605 "a refused join persists nothing"
9606 );
9607
9608 bed.swap_to(&owner);
9611 set_banlist(&bed.relay, &community, &[]).await.unwrap();
9612 bed.swap_to(&member);
9613 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
9614 assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
9615 }
9616
9617 #[tokio::test]
9621 async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
9622 use crate::community::migration;
9623 let (bed, owner, member) = TestBed::new();
9624
9625 bed.swap_to(&owner);
9627 let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
9628 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
9629 let jm = join_material(&v2);
9630
9631 bed.swap_to(&member);
9634 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9635 let v1_cid = v1.id.to_hex();
9636 v1.owner_attestation = Some({
9637 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9638 .finalize(&owner.keys).unwrap().as_json()
9639 });
9640 crate::db::community::save_community(&v1).unwrap();
9641 let v1_channel = v1.channels[0].id.to_hex();
9642
9643 let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
9645 let signpost = migration::MigrationSignpost {
9646 v2_community_id: v2_hex.clone(),
9647 owner_xonly: owner.keys.public_key().to_hex(),
9648 owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
9649 relays: bed.relays.clone(),
9650 name: "Guild".into(),
9651 primary_channel: v1_channel.clone(),
9652 root_epoch: 0,
9653 };
9654 let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
9655 crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
9656
9657 let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
9659 assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
9660
9661 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
9663 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
9664 assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
9665 let _ = v1_channel;
9666
9667 assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
9669 }
9670
9671 #[tokio::test]
9676 async fn owner_wizard_then_member_migrate_and_stitch() {
9677 use crate::community::migration;
9678 let (bed, owner, member) = TestBed::new();
9679 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
9680
9681 bed.swap_to(&owner);
9683 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9684 let v1_cid = v1.id.to_hex();
9685 let v1_channel = v1.channels[0].id.to_hex();
9686 v1.owner_attestation = Some({
9687 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9688 .finalize(&owner.keys).unwrap().as_json()
9689 });
9690 crate::db::community::save_community(&v1).unwrap();
9691
9692 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
9694 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
9695 "owner's own client flipped to v2");
9696 assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
9699 "owner channel stitched to v2");
9700
9701 bed.swap_to(&member);
9703 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9704 m_v1.id = v1.id;
9706 m_v1.server_root_key = v1.server_root_key.clone();
9707 m_v1.channels[0].id = v1.channels[0].id;
9708 m_v1.owner_attestation = v1.owner_attestation.clone();
9709 crate::db::community::save_community(&m_v1).unwrap();
9710
9711 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
9714 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
9715 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
9716 "the FOLD ITSELF flipped the member (auto-drive)");
9717 assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
9718 "member holds the v2 twin");
9719 assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
9721 }
9722
9723 #[tokio::test]
9728 async fn wizard_publishes_the_twin_to_the_cross_device_list() {
9729 use crate::community::migration;
9730 let (bed, owner, _member) = TestBed::new();
9731 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
9732
9733 bed.swap_to(&owner);
9734 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9735 let v1_cid = v1.id.to_hex();
9736 v1.owner_attestation = Some({
9737 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9738 .finalize(&owner.keys).unwrap().as_json()
9739 });
9740 crate::db::community::save_community(&v1).unwrap();
9741
9742 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
9743
9744 let list = fetch_fragments(&bed.relay, &bed.relays).await.unwrap()
9746 .expect("the wizard published a community list").list;
9747 assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
9748 assert!(
9752 !list.tombstones.iter().any(|t| t.community_id == v1_cid),
9753 "migration must not tombstone the v1 community"
9754 );
9755 }
9756
9757 #[tokio::test]
9762 async fn wizard_refuses_while_a_drive_holds_the_claim() {
9763 use crate::community::migration;
9764 let (bed, owner, _member) = TestBed::new();
9765 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
9766
9767 bed.swap_to(&owner);
9768 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9769 let v1_cid = v1.id.to_hex();
9770 v1.owner_attestation = Some({
9771 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9772 .finalize(&owner.keys).unwrap().as_json()
9773 });
9774 crate::db::community::save_community(&v1).unwrap();
9775
9776 migration::test_hold_drive_claim(&v1_cid);
9778 let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
9779 assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
9780 assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
9782 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
9783
9784 migration::test_release_drive_claim(&v1_cid);
9786 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
9787 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
9788 }
9789
9790 #[tokio::test]
9796 async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
9797 use crate::community::migration;
9798 let (bed, owner, _member) = TestBed::new();
9799 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
9800 let relay = std::sync::Arc::new(MemoryRelay::new());
9802
9803 bed.swap_to(&owner);
9804 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9805 let v1_cid = v1.id.to_hex();
9806 let v1_channel = v1.channels[0].id.to_hex();
9807 v1.owner_attestation = Some({
9808 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9809 .finalize(&owner.keys).unwrap().as_json()
9810 });
9811 crate::db::community::save_community(&v1).unwrap();
9812
9813 let twin = create_migration_twin(
9816 &*relay, "Guild", bed.relays.clone(), None,
9817 (v1.channels[0].id, "general".to_string()),
9818 ).await.unwrap();
9819 let v2_id = twin.identity.community_id;
9820 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
9821 crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
9822
9823 let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
9825
9826 let wizard = tokio::spawn({
9827 let relay = relay.clone();
9828 let v1 = v1.clone();
9829 async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
9830 });
9831
9832 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
9834 assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
9835 assert!(
9836 crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
9837 "the fence must not be stamped while the follow lock is held"
9838 );
9839
9840 drop(held);
9842 let flipped = wizard.await.unwrap().unwrap();
9843 assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
9844 assert_eq!(
9845 crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
9846 Some(v2_hex.as_str()),
9847 "the channel row is stitched to the twin, not pruned"
9848 );
9849 }
9850
9851 #[tokio::test]
9856 async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
9857 use crate::community::migration;
9858 let (bed, owner, banned) = TestBed::new();
9859 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
9860
9861 bed.swap_to(&owner);
9863 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9864 let v1_cid = v1.id.to_hex();
9865 v1.owner_attestation = Some({
9866 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9867 .finalize(&owner.keys).unwrap().as_json()
9868 });
9869 crate::db::community::save_community(&v1).unwrap();
9870 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
9871
9872 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
9873
9874 bed.swap_to(&banned);
9876 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9877 m_v1.id = v1.id;
9878 m_v1.server_root_key = v1.server_root_key.clone();
9879 m_v1.channels[0].id = v1.channels[0].id;
9880 m_v1.owner_attestation = v1.owner_attestation.clone();
9881 crate::db::community::save_community(&m_v1).unwrap();
9882 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
9883
9884 let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
9886 let payload = migration::parse_migration_payload(&raw).unwrap();
9887 assert!(payload.m.is_some());
9888 let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
9889 assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
9890 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
9891 assert!(
9892 crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
9893 "banned member never acquires the v2 twin"
9894 );
9895 }
9896
9897 #[tokio::test]
9902 async fn wizard_resume_continues_on_the_same_twin() {
9903 use crate::community::migration;
9904 let (bed, owner, banned) = TestBed::new();
9905 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
9906
9907 bed.swap_to(&owner);
9908 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9909 let mut sibling = v1.channels[0].clone();
9911 sibling.id = crate::community::ChannelId(crate::community::random_32());
9912 sibling.name = "offtopic".into();
9913 v1.channels.push(sibling.clone());
9914 let v1_cid = v1.id.to_hex();
9915 v1.owner_attestation = Some({
9916 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9917 .finalize(&owner.keys).unwrap().as_json()
9918 });
9919 crate::db::community::save_community(&v1).unwrap();
9920 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
9921
9922 let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
9925 let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
9926 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
9927
9928 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
9930 assert_eq!(v2_hex, minted_hex, "no second twin was minted");
9931 let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
9932 assert_eq!(ledger_v2, minted_hex);
9933 assert_eq!(phase, migration::PHASE_FLIPPED);
9934 assert_eq!(
9937 crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
9938 Some(minted_hex.as_str()),
9939 "sibling channel re-parented by the resumed run"
9940 );
9941 let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
9942 let wire_banlist = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
9943 .await
9944 .unwrap()
9945 .banned;
9946 assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
9947 "the resumed banlist clone is folded from the twin's wire control plane");
9948
9949 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
9952 let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
9953 assert_eq!(healed, minted_hex);
9954 let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
9955 assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
9956
9957 let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
9963 let v1b_cid = v1b.id.to_hex();
9964 v1b.owner_attestation = Some({
9965 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
9966 .finalize(&owner.keys).unwrap().as_json()
9967 });
9968 crate::db::community::save_community(&v1b).unwrap();
9969 let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
9970 let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
9971 crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
9972 crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
9974 assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
9975 assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
9976 }
9977
9978 #[tokio::test]
9983 async fn birth_refound_seeds_an_explicit_roster() {
9984 let (bed, owner, _m) = TestBed::new();
9985 bed.swap_to(&owner);
9986 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
9987 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
9988 assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
9989 let ghost_a = Keys::generate().public_key();
9991 let ghost_b = Keys::generate().public_key();
9992
9993 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
9994 assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
9995
9996 let members = memberlist(&bed.relay, &rolled).await.unwrap();
9999 assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
10000 assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
10001
10002 let _ = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
10006 .expect("the epoch-1 twin verifies from its compacted control plane");
10007
10008 let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
10011 assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
10012 assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
10013 }
10014
10015 #[tokio::test]
10019 async fn birth_refound_ignores_a_banned_seed_entry() {
10020 let (bed, owner, _m) = TestBed::new();
10021 bed.swap_to(&owner);
10022 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
10023 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
10024 let good = Keys::generate().public_key();
10025 let banned = Keys::generate();
10026 set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
10028 let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
10029
10030 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
10031 .expect("a banned seed entry is filtered, not a permanent verify-back wedge");
10032 assert_eq!(rolled.root_epoch, Epoch(1));
10033 let members = memberlist(&bed.relay, &rolled).await.unwrap();
10034 assert!(members.contains(&good), "the non-banned seed lands");
10035 assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
10036 }
10037
10038 #[tokio::test]
10044 async fn a_seeded_member_receives_a_later_refound_rekey() {
10045 let (bed, owner, _m) = TestBed::new();
10046 bed.swap_to(&owner);
10047 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
10048 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
10049 let ghost = Keys::generate();
10050 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
10052 assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
10053
10054 let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
10060 assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
10061 assert!(
10062 memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
10063 "a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
10064 );
10065 }
10066
10067 #[tokio::test]
10070 async fn v1_admin_stays_admin_across_migration() {
10071 use crate::community::migration;
10072 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10073 let (bed, owner, admin) = TestBed::new();
10074 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10075
10076 bed.swap_to(&owner);
10077 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10078 let v1_cid = v1.id.to_hex();
10079 v1.owner_attestation = Some({
10080 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10081 .finalize(&owner.keys).unwrap().as_json()
10082 });
10083 crate::db::community::save_community(&v1).unwrap();
10084 let admin_role = Role::admin("a1".repeat(32));
10086 let roles = CommunityRoles {
10087 roles: vec![admin_role.clone()],
10088 grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
10089 };
10090 crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
10091
10092 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10093 let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
10094
10095 let authority = fetch_authority(&bed.relay, &twin).await;
10097 assert!(
10098 authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
10099 "the v1 admin is an admin on the v2 twin"
10100 );
10101 assert!(
10102 !authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
10103 "a non-admin gains no authority"
10104 );
10105 }
10106
10107 #[tokio::test]
10116 async fn an_unsealed_v1_that_was_migrated_away_still_heals() {
10117 use crate::community::migration;
10118 let (bed, owner, member) = TestBed::new();
10119 bed.swap_to(&owner);
10120
10121 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10122 let v1_cid = v1.id.to_hex();
10123 v1.owner_attestation = Some({
10124 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10125 .finalize(&owner.keys).unwrap().as_json()
10126 });
10127 crate::db::community::save_community(&v1).unwrap();
10128
10129 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10131 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10132
10133 bed.swap_to(&member);
10136 crate::db::community::save_community(&v1).unwrap();
10137 assert!(
10138 !crate::db::community::get_community_dissolved(&v1_cid).unwrap(),
10139 "precondition: the wedged device has NOT sealed its v1 row"
10140 );
10141
10142 assert!(
10144 crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10145 "an unsealed, migrated-away v1 must be probed"
10146 );
10147
10148 migration::sweep_dissolved_for_migration(&bed.relay).await;
10149
10150 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "sealed by the sweep");
10152 assert_eq!(
10153 crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(),
10154 Some(v2_hex.as_str()),
10155 "flipped to the same twin the first device produced"
10156 );
10157 assert!(
10158 !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10159 "and the sweep converges — no re-probing forever"
10160 );
10161 }
10162
10163 #[tokio::test]
10168 async fn sweep_marks_checked_only_on_an_owner_tombstone() {
10169 use crate::community::migration;
10170 let (bed, owner, stranger) = TestBed::new();
10171
10172 bed.swap_to(&owner);
10173 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10174 let v1_cid = v1.id.to_hex();
10175 v1.owner_attestation = Some({
10176 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10177 .finalize(&owner.keys).unwrap().as_json()
10178 });
10179 crate::db::community::save_community(&v1).unwrap();
10180
10181 let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
10184 let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
10185 bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
10186 crate::db::community::set_community_dissolved(&v1_cid).unwrap();
10187
10188 migration::sweep_dissolved_for_migration(&bed.relay).await;
10190 assert!(
10191 crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10192 "a stranger-only probe must not converge the sweep"
10193 );
10194
10195 let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
10197 let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
10198 bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
10199 migration::sweep_dissolved_for_migration(&bed.relay).await;
10200 assert!(
10201 !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10202 "an owner plain-dissolution converges the sweep"
10203 );
10204 }
10205
10206 #[tokio::test]
10208 async fn wizard_preflight_gates_timelock_and_ownership() {
10209 use crate::community::migration;
10210 let (bed, owner, _member) = TestBed::new();
10211 bed.swap_to(&owner);
10212 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10213 v1.owner_attestation = Some({
10214 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
10215 .finalize(&owner.keys).unwrap().as_json()
10216 });
10217 crate::db::community::save_community(&v1).unwrap();
10218
10219 let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
10221 assert!(err.contains("not unlocked"), "{err}");
10222 assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
10223 }
10224
10225 #[tokio::test]
10226 async fn public_link_full_loop() {
10227 let (bed, owner, member) = TestBed::new();
10228
10229 bed.swap_to(&owner);
10230 let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
10231 let general = community.channels[0].id;
10232 send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
10233 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10235 assert!(link.url.starts_with("https://vectorapp.io/invite/"));
10236 assert!(link.url.contains('#'), "the fragment carries the token");
10237
10238 bed.swap_to(&member);
10240 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
10241 assert_eq!(joined.id().0, community.id().0);
10242 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
10243 }
10244
10245 #[test]
10246 fn bundle_of_snapshots_the_held_icon() {
10247 let owner = Keys::generate();
10248 let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
10249 let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
10250 let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
10251 c.icon = Some(icon.clone());
10252 let bundle = bundle_of(&c, BundleAudience::Link, None, None, None);
10253 assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
10254 }
10255
10256 #[test]
10257 fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
10258 let (_tmp, _guard, _owner) = init_test_db();
10263 let cur_root = [9u8; 32];
10264 let cid = crate::community::CommunityId([1u8; 32]);
10265 let cid_hex = cid.to_hex();
10266
10267 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10269 assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
10270
10271 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
10273 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
10274 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10275 assert_eq!(roots[0], cur_root, "current root leads");
10276 assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
10277 assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
10278 assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
10280
10281 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
10283 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10284 assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
10285
10286 for e in 3..20u64 {
10288 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
10289 }
10290 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10291 assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
10292 }
10293
10294 #[tokio::test]
10295 async fn public_link_preview_shows_live_name_and_icon_without_joining() {
10296 let (bed, owner, member) = TestBed::new();
10297
10298 bed.swap_to(&owner);
10299 let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
10300 let icon = control::ImageRef {
10303 url: "https://blossom.example/soap".into(),
10304 key: "k".into(),
10305 nonce: "n".into(),
10306 hash: "h".into(),
10307 extra: Default::default(),
10308 };
10309 let mut meta = community.metadata();
10310 meta.icon = Some(icon.clone());
10311 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
10312 let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
10314
10315 bed.swap_to(&member);
10317 let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
10318 assert_eq!(preview.name, "Soapbox");
10319 assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
10320 assert!(
10321 crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
10322 "previewing must not persist a membership"
10323 );
10324 }
10325
10326 #[tokio::test]
10327 async fn a_previewed_join_reuses_the_verified_fold() {
10328 let (bed, owner, member) = TestBed::new();
10329 bed.swap_to(&owner);
10330 let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
10331 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10332
10333 bed.swap_to(&member);
10334 let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
10335 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
10336 assert_eq!(joined.id().0, community.id().0);
10337 assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
10338 assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
10341 }
10342
10343 #[tokio::test]
10344 async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
10345 let (bed, owner, member) = TestBed::new();
10346 bed.swap_to(&owner);
10347 let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
10348 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10349
10350 bed.swap_to(&member);
10351 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
10352
10353 assert!(!sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty(), "the seed folds fresh events");
10355 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
10356 let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
10357 assert!(cursor > 0, "the cursor advanced past zero");
10358 let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
10359 let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
10360 assert_eq!(stored, live, "stored fold == live fold after the seed");
10361 assert!(stored.contains(&member.keys.public_key()));
10362
10363 assert!(sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty());
10365
10366 bed.swap_to(&owner);
10368 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
10369 bed.swap_to(&member);
10370 assert!(!sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty(), "the kick lands incrementally");
10371 assert!(
10372 !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
10373 "an owner kick removes the member from the stored fold"
10374 );
10375 }
10376
10377 #[tokio::test]
10378 async fn a_preview_then_revoke_still_refuses_the_join() {
10379 let (bed, owner, member) = TestBed::new();
10380 bed.swap_to(&owner);
10381 let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
10382 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10383
10384 bed.swap_to(&member);
10386 let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
10387 assert_eq!(p.name, "RevokeRace");
10388 bed.swap_to(&owner);
10389 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
10390 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
10391
10392 bed.swap_to(&member);
10395 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
10396 assert!(err.contains("revoked"), "got: {err}");
10397 }
10398
10399 #[tokio::test]
10400 async fn a_revoked_link_refuses_to_join() {
10401 let (bed, owner, member) = TestBed::new();
10402 bed.swap_to(&owner);
10403 let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
10404 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10405 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
10407 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
10408
10409 bed.swap_to(&member);
10410 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
10411 assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
10412 }
10413
10414 #[tokio::test]
10415 async fn an_expired_direct_invite_refuses_to_join() {
10416 let (bed, owner, member) = TestBed::new();
10417 bed.swap_to(&owner);
10418 let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
10419 let inviter = owner.keys.clone();
10421 let mut bundle = bundle_of(&community, BundleAudience::Link, Some(inviter.public_key()), Some(1_000), None);
10422 bundle.expires_at = Some(1_000); let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
10424 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
10425
10426 bed.swap_to(&member);
10427 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10428 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
10429 assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
10430 }
10431
10432 #[tokio::test]
10433 async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
10434 let (bed, owner, member) = TestBed::new();
10438 bed.swap_to(&owner);
10439 let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
10440 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10441 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
10442
10443 let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
10446
10447 bed.swap_to(&member);
10448 let err = accept_public_link(&union, &link.url).await.unwrap_err();
10449 assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
10450 }
10451
10452 #[test]
10453 fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
10454 let owner = Keys::generate();
10460 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
10461 let hex = crate::simd::hex::bytes_to_hex_32;
10462 let root = [0x11u8; 32];
10463 let mut bundle = CommunityInvite {
10464 community_id: hex(&identity.community_id.0),
10465 owner: hex(&identity.owner_xonly),
10466 owner_salt: hex(&identity.owner_salt),
10467 community_root: hex(&root),
10468 root_epoch: 0,
10469 control_pk: None,
10470 channels: vec![],
10471 relays: vec!["wss://r".into()],
10472 name: "X".into(),
10473 icon: None,
10474 expires_at: None,
10475 creator_npub: None,
10476 label: None,
10477 extra: Default::default(),
10478 };
10479 bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
10480 .map(|i| {
10481 let mut id = [0u8; 32];
10482 id[..8].copy_from_slice(&(i as u64).to_be_bytes());
10483 invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
10484 })
10485 .collect();
10486 assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
10487 }
10488
10489 #[tokio::test]
10490 async fn a_join_swap_between_fetch_and_save_lands_in_the_joining_account() {
10491 let (bed, owner, member) = TestBed::new();
10495 bed.swap_to(&owner);
10496 let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
10497 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10498 bed.swap_to(&member);
10499 let joiner = crate::db::current_session();
10500 let swap_relay = SwapMidFetch::arm(&bed.relay);
10501 accept_public_link(&swap_relay, &link.url).await.unwrap();
10502
10503 assert!(
10504 crate::db::with_session(joiner, async {
10505 crate::db::community::load_community_v2(community.id()).unwrap().is_some()
10506 })
10507 .await,
10508 "the join completed for the account that accepted the link"
10509 );
10510 }
10511
10512 #[tokio::test]
10513 async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
10514 let (_tmp, _guard, owner) = init_test_db();
10517 let relay = MemoryRelay::new();
10518 let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
10519 let empty = MemoryRelay::new();
10522 let members = memberlist(&empty, &community).await.unwrap();
10523 assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
10524 }
10525
10526 #[tokio::test]
10527 async fn an_expiring_minted_invite_refuses_after_the_deadline() {
10528 let (bed, owner, member) = TestBed::new();
10531 bed.swap_to(&owner);
10532 let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
10533 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
10534 .await
10535 .unwrap();
10536
10537 bed.swap_to(&member);
10538 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10539 assert!(
10540 accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
10541 "a minted expiring invite refuses past its deadline"
10542 );
10543 }
10544
10545 #[tokio::test]
10546 async fn a_member_who_leaves_drops_from_the_memberlist() {
10547 let (bed, owner, member) = TestBed::new();
10548 bed.swap_to(&owner);
10549 let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
10550 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
10551
10552 bed.swap_to(&member);
10553 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10554 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
10555 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
10557 leave_community(&bed.relay, &joined).await.unwrap();
10558
10559 bed.swap_to(&owner);
10560 let members = memberlist(&bed.relay, &community).await.unwrap();
10561 assert!(members.contains(&owner.keys.public_key()));
10562 assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
10563 }
10564
10565 #[tokio::test]
10566 async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
10567 let (bed, owner, member) = TestBed::new();
10570 bed.swap_to(&owner);
10571 let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
10572 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
10573
10574 bed.swap_to(&member);
10575 assert!(
10576 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
10577 "the owner's community must be invisible in the member's account DB"
10578 );
10579 assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
10580 }
10581
10582 async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
10590 let group = control::ControlPlane::of(&community).write_group().unwrap();
10591 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
10592 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
10593 let mut head: Option<(u64, [u8; 32])> = None;
10594 for w in &wraps {
10595 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
10596 if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
10597 head = Some((ed.version, ed.self_hash));
10598 }
10599 }
10600 }
10601 head.map(|(_, h)| h)
10602 }
10603
10604 async fn cite_on_relay(
10609 relay: &MemoryRelay,
10610 community: &CommunityV2,
10611 signer: &Keys,
10612 ) -> Option<crate::community::edition::AuthorityCitation> {
10613 if community.owner().ok() == Some(signer.public_key()) {
10614 return None;
10615 }
10616 let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
10617 let group = control::ControlPlane::of(&community).write_group().unwrap();
10618 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
10619 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
10620 let mut head: Option<(u64, [u8; 32])> = None;
10621 for w in &wraps {
10622 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
10623 if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
10624 head = Some((ed.version, ed.self_hash));
10625 }
10626 }
10627 }
10628 head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
10629 }
10630
10631 async fn publish_channel_edition(
10632 relay: &MemoryRelay,
10633 community: &CommunityV2,
10634 signer: &Keys,
10635 channel_id: &ChannelId,
10636 name: &str,
10637 private: bool,
10638 version: u64,
10639 deleted: bool,
10640 ) {
10641 let group = control::ControlPlane::of(&community).write_group().unwrap();
10642 let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
10643 let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
10644 let content = serde_json::to_string(&meta).unwrap();
10645 let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
10646 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
10647 relay.publish(&wrap, &community.relays).await.unwrap();
10648 }
10649
10650 #[allow(clippy::too_many_arguments)]
10654 async fn publish_channel_edition_at(
10655 relay: &MemoryRelay,
10656 community: &CommunityV2,
10657 signer: &Keys,
10658 channel_id: &ChannelId,
10659 name: &str,
10660 version: u64,
10661 at_secs: u64,
10662 ) {
10663 let group = control::ControlPlane::of(community).write_group().unwrap();
10664 let meta = control::ChannelMetadata { name: name.into(), private: false, deleted: None, ..Default::default() };
10665 let content = serde_json::to_string(&meta).unwrap();
10666 let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, None, &content, at_secs, None);
10667 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
10668 relay.publish(&wrap, &community.relays).await.unwrap();
10669 }
10670
10671 async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
10674 publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
10675 }
10676
10677 async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
10680 let group = control::ControlPlane::of(&community).write_group().unwrap();
10681 let prev = head_hash_on_relay(relay, community, &community.id().0).await;
10682 let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
10683 let content = serde_json::to_string(&meta).unwrap();
10684 let cite = cite_on_relay(relay, community, signer).await;
10685 let rumor = control::build_edition_rumor(signer.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, version, prev.as_ref(), &content, at_secs, cite.as_ref());
10686 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
10687 relay.publish(&wrap, &community.relays).await.unwrap();
10688 }
10689
10690 #[test]
10691 fn metadata_apply_captures_undriven_fields_for_republish() {
10692 let owner = Keys::generate();
10693 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
10694 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
10695 let general = held.channels[0].id;
10696
10697 let mut custom = serde_json::Map::new();
10699 custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
10700 let mut extra = serde_json::Map::new();
10701 extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
10702 let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
10703 assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
10704 assert_eq!(held.meta_custom, Some(custom.clone()));
10705 assert_eq!(held.meta_extra, extra);
10706 assert_eq!(held.metadata().custom, Some(custom));
10708 assert_eq!(held.metadata().extra, held.meta_extra);
10709
10710 let mut ch_custom = serde_json::Map::new();
10712 ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
10713 let ch_meta = control::ChannelMetadata {
10714 name: "general".into(),
10715 private: false,
10716 voice: Some(true),
10717 deleted: None,
10718 custom: Some(ch_custom.clone()),
10719 extra: Default::default(),
10720 };
10721 assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
10722 let ch = held.channel(&general).unwrap();
10723 assert_eq!(ch.voice, Some(true));
10724 assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
10725 let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
10726 assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
10727 assert_eq!(rename.custom, Some(ch_custom));
10728 }
10729
10730 #[test]
10731 fn community_metadata_apply_sets_and_clears_images() {
10732 let owner = Keys::generate();
10733 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
10734 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
10735
10736 let icon = control::ImageRef {
10737 url: "https://blossom.example/i".into(),
10738 key: "k".into(),
10739 nonce: "n".into(),
10740 hash: "h".into(),
10741 extra: Default::default(),
10742 };
10743 let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
10744 assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
10745 assert_eq!(held.icon.as_ref(), Some(&icon));
10746
10747 let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
10749 assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
10750 assert_eq!(held.icon, None);
10751 }
10752
10753 async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
10755 publish_role_at(relay, community, signer, role, version, 1_000).await
10756 }
10757
10758 async fn publish_filler_edition(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64, ts: u64) {
10762 let group = control::ControlPlane::of(&community).write_group().unwrap();
10763 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
10764 let content = crate::community::v2::roles::role_content_json(role).unwrap();
10765 let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, None, &content, ts, None);
10766 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(ts)).unwrap();
10767 relay.publish(&wrap, &community.relays).await.unwrap();
10768 }
10769
10770 async fn publish_role_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64, ts: u64) {
10775 let group = control::ControlPlane::of(&community).write_group().unwrap();
10776 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
10777 let prev = head_hash_on_relay(relay, community, &role_id).await;
10778 let content = crate::community::v2::roles::role_content_json(role).unwrap();
10779 let cite = cite_on_relay(relay, community, signer).await;
10780 let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, ts, cite.as_ref());
10781 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(ts)).unwrap();
10782 relay.publish(&wrap, &community.relays).await.unwrap();
10783 }
10784
10785 fn rotate_view(c: &CommunityV2, new_root: [u8; 32], epoch: u64) -> CommunityV2 {
10789 let mut r = c.clone();
10790 r.community_root = new_root;
10791 r.root_epoch = Epoch(epoch);
10792 r.control_root = Some(new_root);
10793 r.control_pk = Some(crate::community::v2::derive::control_signer_group_key(&new_root, r.id(), Epoch(epoch)).pk());
10794 r
10795 }
10796
10797 async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
10803 let group = control::ControlPlane::of(&community).write_group().unwrap();
10804 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
10805 let prev = head_hash_on_relay(relay, community, &eid).await;
10806 let grant = MemberGrant { member: member.to_hex(), role_ids };
10807 let control_wrap = community.control_root.filter(|_| community.control_pk.is_some()).map(|cr| {
10808 let ck = nostr_sdk::prelude::nip44::v2::ConversationKey::derive(signer.secret_key(), member).unwrap();
10809 let payload = crate::community::cipher::encrypt_with_random_nonce(&ck, super::rekey::control_wrap_b64(community.root_epoch, &cr).as_bytes()).unwrap();
10810 base64_simd::STANDARD.encode_to_string(&payload)
10811 });
10812 let content = crate::community::v2::roles::grant_content_json_with_wrap(&grant, control_wrap).unwrap();
10813 let cite = cite_on_relay(relay, community, signer).await;
10814 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
10815 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
10816 relay.publish(&wrap, &community.relays).await.unwrap();
10817 }
10818
10819 async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
10821 let group = control::ControlPlane::of(&community).write_group().unwrap();
10822 let eid = crate::community::v2::derive::banlist_locator(community.id());
10823 let prev = head_hash_on_relay(relay, community, &eid).await;
10824 let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
10825 let cite = cite_on_relay(relay, community, signer).await;
10826 let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
10827 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
10828 relay.publish(&wrap, &community.relays).await.unwrap();
10829 }
10830
10831 fn admin_role(role_id: &str, perms: u64) -> Role {
10832 Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
10833 }
10834
10835 async fn create_legacy_community(relay: &MemoryRelay, owner: &Keys, name: &str, relays: Vec<String>) -> CommunityV2 {
10840 let identity = control::CommunityIdentity::mint(&owner.public_key());
10841 let root = crate::community::random_32();
10842 let group = control_group_key(&root, &identity.community_id, Epoch(0));
10843 let meta = control::CommunityMetadata { name: name.into(), relays: relays.clone(), ..Default::default() };
10844 let meta_rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &identity.community_id.0, 1, None, &serde_json::to_string(&meta).unwrap(), 1_000, None);
10845 let ch_id = ChannelId(crate::community::random_32());
10846 let ch = control::ChannelMetadata { name: "general".into(), private: false, ..Default::default() };
10847 let ch_rumor = control::build_edition_rumor(owner.public_key(), vsk::CHANNEL_METADATA, &ch_id.0, 1, None, &serde_json::to_string(&ch).unwrap(), 1_000, None);
10848 for r in [&meta_rumor, &ch_rumor] {
10849 let (w, _) = control::seal_control_edition(r, &group, owner, Timestamp::from_secs(1_000)).unwrap();
10850 relay.publish(&w, &relays).await.unwrap();
10851 }
10852 let community = CommunityV2 {
10853 identity,
10854 community_root: root,
10855 root_epoch: Epoch(0),
10856 control_pk: None,
10857 control_root: None,
10858 name: name.into(),
10859 description: None,
10860 icon: None,
10861 banner: None,
10862 meta_custom: None,
10863 meta_extra: Default::default(),
10864 relays,
10865 channels: vec![ChannelV2 { id: ch_id, name: "general".into(), private: false, key: None, epoch: Epoch(0), voice: None, meta_custom: None, meta_extra: Default::default() }],
10866 dissolved: false,
10867 created_at_ms: 1_000_000,
10868 };
10869 crate::db::community::save_community_v2(&community).unwrap();
10870 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10871 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &root);
10872 let _ = follow_control(relay, &community).await;
10873 crate::db::community::load_community_v2(community.id()).unwrap().unwrap()
10874 }
10875
10876 #[tokio::test]
10877 async fn a_refounding_mints_the_split_and_delivers_the_pair_by_staffness() {
10878 let (bed, owner, admin) = TestBed::new();
10883 bed.swap_to(&owner);
10884 let community = create_community(&bed.relay, "SplitRefound", bed.relays.clone(), None).await.unwrap();
10885 assert!(community.control_pk.is_some() && community.control_root.is_some(), "genesis mints the split");
10886
10887 let rid = "d1".repeat(32);
10890 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
10891 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
10892 let plain = Keys::generate();
10893 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
10894 let join = guestbook::build_join_rumor(plain.public_key(), None, 1_000);
10895 let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &plain, Timestamp::from_secs(1_000)).unwrap();
10896 bed.relay.publish(&w, &community.relays).await.unwrap();
10897
10898 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
10900 bed.swap_to(&admin);
10901 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10902 assert_eq!(joined.control_pk, community.control_pk, "the bundle handed over the address");
10903 assert_eq!(joined.control_root, None, "a bundle never carries the secret");
10904 let _ = follow_control(&bed.relay, &joined).await;
10905 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10906 assert_eq!(joined.control_root, community.control_root, "the Grant's control_wrap delivered the secret");
10907
10908 bed.swap_to(&owner);
10910 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
10911 assert_eq!(refounded.root_epoch, Epoch(1));
10912 assert!(refounded.control_pk.is_some() && refounded.control_root.is_some());
10913 assert_ne!(refounded.control_pk, community.control_pk, "the pair rolls with the root");
10914
10915 let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
10918 let chunks = fetch_rekey_chunks(&bed.relay, &community.relays, &base_group).await.unwrap();
10919 let rotation = &rekey::collect_rotations(&chunks)[0];
10920 async fn open_as(rotation: &rekey::Rotation, community_id: &crate::community::CommunityId, keys: &Keys) -> rekey::BaseKeyDelivery {
10921 let blob = rekey::find_my_blob(&rotation.blobs, &rotation.rotator.to_bytes(), &keys.public_key().to_bytes(), rotation.scope, rotation.new_epoch).expect("a blob for every recipient");
10922 let signer = crate::signer::ActiveSigner::Keys(keys.clone());
10923 rekey::open_base_blob(&signer, &rotation.rotator, community_id, Epoch(1), blob).await.unwrap()
10924 }
10925 let admin_delivery = open_as(rotation, community.id(), &admin.keys).await;
10926 assert_eq!(admin_delivery.control_root, refounded.control_root, "staff blob = 136, secret included");
10927 let plain_delivery = open_as(rotation, community.id(), &plain).await;
10928 assert_eq!(plain_delivery.control_pk.map(|p| PublicKey::from_slice(p.as_slice()).unwrap()), refounded.control_pk);
10929 assert_eq!(plain_delivery.control_root, None, "member blob = 104, never the secret");
10930
10931 let legacy_new = control_group_key(&refounded.community_root, community.id(), Epoch(1));
10934 let q = |pk: String| Query { kinds: vec![stream::KIND_WRAP], authors: vec![pk], limit: Some(50), ..Default::default() };
10935 assert!(bed.relay.fetch(&q(legacy_new.pk_hex()), &community.relays).await.unwrap().is_empty(), "no legacy mirror");
10936 assert!(!bed.relay.fetch(&q(refounded.control_pk.unwrap().to_hex()), &community.relays).await.unwrap().is_empty(), "the compaction rides the split address");
10937
10938 bed.swap_to(&admin);
10940 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
10941 let adopted = follow.updated.expect("the admin adopts the rotation");
10942 assert_eq!(adopted.root_epoch, Epoch(1));
10943 assert_eq!(adopted.control_pk, refounded.control_pk);
10944 assert_eq!(adopted.control_root, refounded.control_root, "staff crossing a rotation get the new secret in the blob");
10945 }
10946
10947 #[tokio::test]
10948 async fn a_rescue_lets_a_stranded_member_walk_every_missed_hop() {
10949 let (_tmp, _guard, _owner) = init_test_db();
10954 let relay = MemoryRelay::new();
10955 let community = create_community(&relay, "Stranded", vec!["wss://r".into()], None).await.unwrap();
10956 let stranded = Keys::generate();
10957
10958 let e1 = refound_community(&relay, &community, &[]).await.unwrap();
10961 let e2 = refound_community(&relay, &e1, &[]).await.unwrap();
10962 assert_eq!(e2.root_epoch, Epoch(2));
10963
10964 let hop1 = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
10966 let chunks = fetch_rekey_chunks(&relay, &community.relays, &hop1).await.unwrap();
10967 let r1 = &rekey::collect_rotations(&chunks)[0];
10968 assert!(
10969 rekey::find_my_blob(&r1.blobs, &r1.rotator.to_bytes(), &stranded.public_key().to_bytes(), r1.scope, r1.new_epoch).is_none(),
10970 "genuinely stranded before the rescue"
10971 );
10972
10973 let report = rescue_stranded_members(&relay, &e2, &[stranded.public_key()]).await.unwrap();
10974 assert_eq!(report.hops, 2, "one catch-up per missed hop");
10975 assert_eq!(report.hops_skipped, 0);
10976
10977 let signer = crate::signer::ActiveSigner::Keys(stranded.clone());
10980 let mut held_root = community.community_root;
10981 for e in 1..=2u64 {
10982 let group = base_rekey_group_key(&held_root, community.id(), Epoch(e));
10983 let chunks = fetch_rekey_chunks(&relay, &community.relays, &group).await.unwrap();
10984 let rotations = rekey::collect_rotations(&chunks);
10985 let rot = rotations
10986 .iter()
10987 .find(|r| {
10988 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &stranded.public_key().to_bytes(), r.scope, r.new_epoch).is_some()
10989 })
10990 .expect("a rotation now carries our blob");
10991 let blob = rekey::find_my_blob(&rot.blobs, &rot.rotator.to_bytes(), &stranded.public_key().to_bytes(), rot.scope, rot.new_epoch).unwrap();
10992 let delivery = rekey::open_base_blob(&signer, &rot.rotator, community.id(), Epoch(e), blob).await.unwrap();
10993 held_root = delivery.new_root;
10994 }
10995 assert_eq!(held_root, e2.community_root, "the walk ends on the live root");
10996
10997 let chunks = fetch_rekey_chunks(&relay, &community.relays, &hop1).await.unwrap();
11002 let rotations = rekey::collect_rotations(&chunks);
11003 assert_eq!(rotations.len(), 1, "catch-up merged into the original rotation, not a fork");
11004 assert!(rotations[0].is_complete(), "the extended set still reads complete");
11005 let owner_pk = e2.owner().unwrap();
11006 assert!(
11007 rekey::find_my_blob(&rotations[0].blobs, &rotations[0].rotator.to_bytes(), &owner_pk.to_bytes(), rotations[0].scope, rotations[0].new_epoch).is_some(),
11008 "the original recipient's blob survived the extension"
11009 );
11010
11011 let wire = wire_guestbook_members(&relay, &e2).await.unwrap();
11014 assert!(wire.contains(&stranded.public_key()), "the invite page's fold now includes them");
11015 }
11016
11017 #[tokio::test]
11018 async fn a_non_owner_refounders_snapshot_seeds_the_roster() {
11019 let (_tmp, _guard, _owner) = init_test_db();
11025 let relay = MemoryRelay::new();
11026 let community = create_community(&relay, "MintedBy", vec!["wss://r".into()], None).await.unwrap();
11027 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11028 let owner = community.owner().unwrap();
11029
11030 let admin = Keys::generate();
11033 let mut at_epoch_1 = community.clone();
11034 at_epoch_1.root_epoch = Epoch(1);
11035 record_epoch_minter(&cid_hex, 1, &admin.public_key().to_hex());
11036
11037 let cut = Keys::generate().public_key();
11040 let join = guestbook::GuestbookEvent {
11041 epoch: None, observed_at: None, rumor_id: [11u8; 32],
11042 entry: guestbook::GuestbookEntry::Join { member: cut, invited_by: None, at_ms: 1_000 },
11043 };
11044 let snapshot = |by: &Keys, id: u8| guestbook::GuestbookEvent {
11045 epoch: Some(1), observed_at: None, rumor_id: [id; 32],
11046 entry: guestbook::GuestbookEntry::Snapshot {
11047 refounder: by.public_key(),
11048 members: vec![owner], snapshot_id: [id; 32],
11050 chunk: (1, 1),
11051 at_ms: 5_000, },
11053 };
11054
11055 let stranger = Keys::generate();
11058 crate::db::community::set_guestbook(&cid_hex, &[join.clone(), snapshot(&stranger, 0x51)], 6).unwrap();
11059 assert!(
11060 stored_memberlist(&at_epoch_1).unwrap().contains(&cut),
11061 "a snapshot from an npub that minted nothing seeds no roster"
11062 );
11063
11064 crate::db::community::set_guestbook(&cid_hex, &[join, snapshot(&admin, 0x52)], 6).unwrap();
11066 let members = stored_memberlist(&at_epoch_1).unwrap();
11067 assert!(
11068 !members.contains(&cut),
11069 "the refounder's survivor list is adopted, so a cut member stops being one"
11070 );
11071 assert!(members.contains(&owner), "a survivor the snapshot names stays");
11072 }
11073
11074 #[tokio::test]
11080 async fn reading_a_private_channel_needs_its_own_role_not_community_power() {
11081 let (bed, owner, mod_without_access) = TestBed::new();
11082 bed.swap_to(&owner);
11083 let community = create_community(&bed.relay, "Rooms", bed.relays.clone(), None).await.unwrap();
11084 let public = community.channels[0].id;
11085 let private = create_private_channel(&bed.relay, &community, "back-room").await.unwrap();
11086 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11087 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11088
11089 use nostr_sdk::prelude::ToBech32;
11090 let them = mod_without_access.keys.public_key();
11091 let them_npub = them.to_bech32().unwrap();
11092
11093 let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa7; 32]);
11096 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
11097 publish_grant(&bed.relay, &community, &owner.keys, &them, vec![admin_rid], 1).await;
11098 follow_control(&bed.relay, &community).await.unwrap();
11099 assert!(
11100 crate::VectorCore.member_has_permission(&cid_hex, &them_npub, Permissions::BAN).unwrap(),
11101 "they really do hold BAN"
11102 );
11103
11104 let pub_hex = crate::simd::hex::bytes_to_hex_32(&public.0);
11105 let priv_hex = crate::simd::hex::bytes_to_hex_32(&private.0);
11106 assert!(
11107 crate::VectorCore.member_can_read_channel(&cid_hex, &pub_hex, &them_npub).unwrap(),
11108 "a public channel is the community's"
11109 );
11110 assert!(
11111 !crate::VectorCore.member_can_read_channel(&cid_hex, &priv_hex, &them_npub).unwrap(),
11112 "BAN is not a key: community power must never open a private room"
11113 );
11114
11115 grant_channel_access(&bed.relay, &community, &private, &them).await.unwrap();
11117 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11118 let _ = follow_control(&bed.relay, &community).await;
11119 assert!(
11120 crate::VectorCore.member_can_read_channel(&cid_hex, &priv_hex, &them_npub).unwrap(),
11121 "the channel-scoped grant is what admits them"
11122 );
11123 }
11124
11125 #[tokio::test]
11137 async fn an_empty_retain_cuts_nobody_rather_than_keeping_nobody() {
11138 let (bed, owner, member) = TestBed::new();
11139 bed.swap_to(&owner);
11140 let community = create_community(&bed.relay, "EmptyRetain", bed.relays.clone(), None).await.unwrap();
11141
11142 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
11145 bed.swap_to(&member);
11146 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
11147 bed.swap_to(&owner);
11148 assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()));
11149
11150 refound_community(&bed.relay, &community, &[]).await.expect("cutting nobody must succeed");
11154 assert!(
11155 vended_to(&bed, &community, &member.keys).await,
11156 "a plain rotation must vend to a member who is neither the rotator nor the owner"
11157 );
11158 }
11159
11160 #[test]
11164 fn an_empty_retain_does_not_become_an_allow_list() {
11165 let src = include_str!("../../lib.rs");
11166 let at = src.find("pub async fn purge_and_refound").expect("purge_and_refound");
11167 let body = &src[at..at + 3000];
11168 let guard = body.find("if keep.is_empty()").expect("an empty retain must branch away from the allow-list");
11169 let retaining = body.find("refound_community_retaining").expect("the allow-list call");
11170 assert!(guard < retaining, "the branch has to come BEFORE the allow-list call");
11171 }
11172
11173 #[tokio::test]
11181 async fn a_rotation_that_would_strand_staff_refuses_to_publish() {
11182 let (bed, owner, staffer) = TestBed::new();
11183 bed.swap_to(&owner);
11184 let community = create_community(&bed.relay, "NoSilentSever", bed.relays.clone(), None).await.unwrap();
11185 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11186 let them = staffer.keys.public_key();
11187
11188 let rid = crate::simd::hex::bytes_to_hex_32(&[0xb6; 32]);
11189 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11190 publish_grant(&bed.relay, &community, &owner.keys, &them, vec![rid], 1).await;
11191 follow_control(&bed.relay, &community).await.unwrap();
11192
11193 let err = refound_community_retaining(&bed.relay, &community, &[], &[owner.keys.public_key()])
11196 .await
11197 .expect_err("must refuse rather than strand staff");
11198 assert!(
11199 err.contains("stranded"),
11200 "the refusal has to say what it protected, got: {err}"
11201 );
11202
11203 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11205 assert_eq!(held.root_epoch, community.root_epoch, "no state published");
11206 assert!(!vended_to(&bed, &community, &staffer.keys).await, "no half-rotation left on the plane");
11207 let _ = cid_hex;
11208 }
11209
11210 #[tokio::test]
11220 async fn a_granted_member_who_talks_is_not_dropped_by_an_old_snapshot() {
11221 let (bed, owner, staffer) = TestBed::new();
11222 bed.swap_to(&owner);
11223 let community = create_community(&bed.relay, "TalkingAdmin", bed.relays.clone(), None).await.unwrap();
11224 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11225 let them = staffer.keys.public_key();
11226
11227 let rid = crate::simd::hex::bytes_to_hex_32(&[0xb5; 32]);
11228 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11229 publish_grant(&bed.relay, &community, &owner.keys, &them, vec![rid], 1).await;
11230 follow_control(&bed.relay, &community).await.unwrap();
11231
11232 let spoke_at = 5_000u64;
11235 let join = guestbook::GuestbookEvent {
11236 epoch: Some(community.root_epoch.0), observed_at: None, rumor_id: [0x31; 32],
11237 entry: guestbook::GuestbookEntry::Join { member: them, invited_by: None, at_ms: spoke_at },
11238 };
11239 let snapshot = guestbook::GuestbookEvent {
11241 epoch: Some(community.root_epoch.0), observed_at: None, rumor_id: [0x32; 32],
11242 entry: guestbook::GuestbookEntry::Snapshot {
11243 refounder: owner.keys.public_key(),
11244 members: vec![owner.keys.public_key()], snapshot_id: [0x32; 32],
11246 chunk: (1, 1),
11247 at_ms: spoke_at + 1_000,
11248 },
11249 };
11250 crate::db::community::set_guestbook(&cid_hex, &[join, snapshot], 9).unwrap();
11251
11252 assert!(
11253 stored_memberlist(&community).unwrap().contains(&them),
11254 "a grant is proof of membership — an old snapshot must not shed the staff it forgot"
11255 );
11256
11257 let _ = refound_community(&bed.relay, &community, &[]).await.unwrap();
11258 assert!(
11259 vended_to(&bed, &community, &staffer.keys).await,
11260 "and the rotation must vend to them, or they are stranded holding a grant"
11261 );
11262 }
11263
11264 #[tokio::test]
11276 async fn a_rotation_vends_to_a_granted_member_the_folds_forgot() {
11277 let (bed, owner, staffer) = TestBed::new();
11278 bed.swap_to(&owner);
11279 let community = create_community(&bed.relay, "LurkingAdmin", bed.relays.clone(), None).await.unwrap();
11280 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11281 let them = staffer.keys.public_key();
11282
11283 let rid = crate::simd::hex::bytes_to_hex_32(&[0xb4; 32]);
11286 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11287 publish_grant(&bed.relay, &community, &owner.keys, &them, vec![rid], 1).await;
11288 follow_control(&bed.relay, &community).await.unwrap();
11289 assert!(
11290 crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&them.to_hex()),
11291 "they hold the grant"
11292 );
11293
11294 crate::db::community::set_guestbook(&cid_hex, &[], 1).unwrap();
11296
11297 let _rotated = refound_community(&bed.relay, &community, &[]).await.unwrap();
11298
11299 assert!(
11303 vended_to(&bed, &community, &staffer.keys).await,
11304 "a rotation removing nobody must vend to a granted member, or it severs its own staff"
11305 );
11306 }
11307
11308 #[tokio::test]
11313 async fn a_rotation_that_removes_nobody_vends_to_everybody() {
11314 let (bed, owner, member) = TestBed::new();
11315 bed.swap_to(&owner);
11316 let community = create_community(&bed.relay, "KeepAll", bed.relays.clone(), None).await.unwrap();
11317
11318 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
11320 bed.swap_to(&member);
11321 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
11322 bed.swap_to(&owner);
11323 let before = memberlist(&bed.relay, &community).await.unwrap();
11324 assert!(before.contains(&member.keys.public_key()), "present before the rotation");
11325
11326 let rotated = refound_community(&bed.relay, &community, &[]).await.unwrap();
11329 assert_eq!(rotated.root_epoch.0, community.root_epoch.0 + 1, "the epoch rolled");
11330
11331 let after = memberlist(&bed.relay, &community).await.unwrap();
11332 assert!(
11333 after.contains(&member.keys.public_key()),
11334 "a rotation removing nobody must keep everybody: {before:?} -> {after:?}"
11335 );
11336
11337 bed.swap_to(&member);
11340 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11341 let follow = follow_rekeys(&bed.relay, &held, &crate::db::current_session()).await.unwrap();
11342 assert!(!follow.self_removed, "they were not excluded");
11343 let now = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11344 assert_eq!(now.root_epoch.0, rotated.root_epoch.0, "they adopted the new epoch — a blob was vended to them");
11345 }
11346
11347 #[tokio::test]
11354 async fn revoking_admin_publishes_where_everyone_else_reads_it() {
11355 let (bed, owner, member) = TestBed::new();
11356 bed.swap_to(&owner);
11357 let community = create_community(&bed.relay, "Demotion", bed.relays.clone(), None).await.unwrap();
11358 let them = member.keys.public_key();
11359 let them_hex = them.to_hex();
11360
11361 grant_admin(&bed.relay, &community, &them).await.unwrap();
11362 let view = fetch_authority(&bed.relay, &community).await;
11363 assert!(view.roles.is_admin(&them_hex), "granted, and readable from the plane");
11364
11365 revoke_admin(&bed.relay, &community, &them).await.unwrap();
11366
11367 let view = fetch_authority(&bed.relay, &community).await;
11369 assert!(
11370 !view.roles.is_admin(&them_hex),
11371 "the demotion must be on the plane, or only the owner believes it"
11372 );
11373
11374 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11376 use nostr_sdk::prelude::ToBech32;
11377 assert!(
11378 !crate::VectorCore
11379 .member_has_permission(&cid_hex, &them.to_bech32().unwrap(), crate::community::roles::Permissions::BAN)
11380 .unwrap(),
11381 "a demoted admin holds no powers"
11382 );
11383 }
11384
11385 #[tokio::test]
11391 async fn permission_is_refused_to_everyone_the_roster_does_not_name() {
11392 let (_tmp, _guard, _owner) = init_test_db();
11393 let relay = MemoryRelay::new();
11394 let community = create_community(&relay, "Authority", vec!["wss://r".into()], None).await.unwrap();
11395 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11396 use nostr_sdk::prelude::ToBech32;
11397 let owner_npub = community.owner().unwrap().to_bech32().unwrap();
11398 let stranger = Keys::generate().public_key().to_bech32().unwrap();
11399
11400 use crate::community::roles::Permissions;
11401 for p in [Permissions::KICK, Permissions::BAN, Permissions::MANAGE_ROLES] {
11402 assert!(
11403 !crate::VectorCore.member_has_permission(&cid_hex, &stranger, p).unwrap(),
11404 "an npub the roster never granted anything holds nothing"
11405 );
11406 assert!(
11407 crate::VectorCore.member_has_permission(&cid_hex, &owner_npub, p).unwrap(),
11408 "the owner is supreme without a grant to cite"
11409 );
11410 }
11411
11412 crate::db::community::set_community_banlist(&cid_hex, &[community.owner().unwrap().to_hex()], 1).unwrap();
11414 let banned_owner_is_still_owner =
11415 crate::VectorCore.member_has_permission(&cid_hex, &owner_npub, Permissions::BAN).unwrap();
11416 assert!(banned_owner_is_still_owner, "the owner cannot be locked out of their own community");
11417
11418 assert!(crate::VectorCore.member_has_permission(&cid_hex, "not-an-npub", Permissions::BAN).is_err());
11420 }
11421
11422 #[tokio::test]
11423 async fn a_refound_keeps_a_quiet_member_the_network_fold_forgot() {
11424 let (_tmp, _guard, _owner) = init_test_db();
11429 let relay = MemoryRelay::new();
11430 let community = create_community(&relay, "QuietOnes", vec!["wss://r".into()], None).await.unwrap();
11431 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11432
11433 let quiet = Keys::generate().public_key();
11436 let ev = guestbook::GuestbookEvent { epoch: None, observed_at: None,
11437 rumor_id: [7u8; 32],
11438 entry: guestbook::GuestbookEntry::Join { member: quiet, invited_by: None, at_ms: 1_000 },
11439 };
11440 crate::db::community::set_guestbook(&cid_hex, std::slice::from_ref(&ev), 1).unwrap();
11441 assert!(stored_memberlist(&community).unwrap().contains(&quiet), "locally known");
11442 assert!(!memberlist(&relay, &community).await.unwrap().contains(&quiet), "network fold has forgotten them");
11443
11444 let rotated = refound_community(&relay, &community, &[]).await.unwrap();
11445
11446 let wire = wire_guestbook_members(&relay, &rotated).await.unwrap();
11449 assert!(wire.contains(&quiet), "the quiet member survived the rotation onto the wire");
11450
11451 let rotated2 = refound_community(&relay, &rotated, &[quiet]).await.unwrap();
11453 let wire2 = wire_guestbook_members(&relay, &rotated2).await.unwrap();
11454 assert!(!wire2.contains(&quiet), "an explicit removal is honoured");
11455 }
11456
11457 #[tokio::test]
11458 async fn revoking_a_husk_prunes_the_local_row_instead_of_erroring_forever() {
11459 let (_tmp, _guard, _owner) = init_test_db();
11465 let relay = MemoryRelay::new();
11466 let community = create_community(&relay, "Husks", vec!["wss://r".into()], None).await.unwrap();
11467 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11468
11469 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
11470 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
11471 assert_eq!(crate::db::community::list_public_invites(&cid_hex).unwrap().len(), 1);
11472
11473 let signer = crate::signer::active_signer().unwrap();
11475 let my_pk = me_pk().unwrap();
11476 let mut list = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
11477 list.entries.retain(|e| e.token != token_hex);
11478 let ev = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.unwrap();
11479 relay.publish(&ev, &community.relays).await.unwrap();
11480
11481 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
11483 assert!(
11484 crate::db::community::list_public_invites(&cid_hex).unwrap().is_empty(),
11485 "the husk row is gone — the panel can no longer show a link nobody can use or remove"
11486 );
11487
11488 }
11492
11493 #[tokio::test]
11494 async fn revoking_the_last_link_privatizes_and_rotates_but_an_earlier_revoke_does_not() {
11495 let (_tmp, _guard, _owner) = init_test_db();
11501 let relay = MemoryRelay::new();
11502 let community = create_community(&relay, "Privatize", vec!["wss://r".into()], None).await.unwrap();
11503 assert_eq!(community.root_epoch, Epoch(0));
11504
11505 let a = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
11506 let b = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
11507 assert!(community_is_public(&relay, &community).await, "two live links = Public");
11508
11509 revoke_public_link(&relay, &community, &crate::simd::hex::bytes_to_hex_16(&a.token)).await.unwrap();
11511 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11512 assert_eq!(held.root_epoch, Epoch(0), "a link remains live — not the Public→Private conversion");
11513 assert!(community_is_public(&relay, &held).await, "still Public");
11514
11515 revoke_public_link(&relay, &held, &crate::simd::hex::bytes_to_hex_16(&b.token)).await.unwrap();
11517 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11518 assert!(!community_is_public(&relay, &held).await, "no live links = Private");
11519 assert_eq!(held.root_epoch, Epoch(1), "privatizing rotated the base key");
11520 assert!(held.control_pk.is_some(), "the privatize rotation mints the split like any base rotation");
11523 assert_ne!(held.community_root, community.community_root, "the base key actually rolled");
11524 }
11525
11526 #[tokio::test]
11527 async fn a_legacy_community_upgrades_as_a_side_effect_of_its_next_refounding() {
11528 let (_tmp, _guard, owner) = init_test_db();
11531 let relay = MemoryRelay::new();
11532 let community = create_legacy_community(&relay, &owner, "Legacy", vec!["wss://r".into()]).await;
11533 assert!(community.control_pk.is_none(), "starts pre-split");
11534
11535 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
11536 assert_eq!(refounded.root_epoch, Epoch(1));
11537 assert!(refounded.control_pk.is_some() && refounded.control_root.is_some(), "the rotation minted the split");
11538 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11540 assert_eq!(reloaded.control_pk, refounded.control_pk);
11541 assert_eq!(reloaded.control_root, refounded.control_root);
11542 }
11543
11544 #[tokio::test]
11545 async fn an_old_build_deletion_recovers_from_the_stale_bundle_across_the_split() {
11546 let (bed, owner, member) = TestBed::new();
11552 bed.swap_to(&owner);
11553 let community = create_legacy_community(&bed.relay, &owner.keys, "Recover", bed.relays.clone()).await;
11554 let stale_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
11555
11556 bed.swap_to(&member);
11558 let joined = accept_parked_invite(&bed.relay, &stale_bundle, None).await.unwrap();
11559 assert!(joined.control_pk.is_none());
11560
11561 bed.swap_to(&owner);
11563 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
11564
11565 bed.swap_to(&member);
11567 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11568 crate::db::community::delete_community(&cid_hex).unwrap();
11569 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
11570
11571 let readopted = accept_parked_invite(&bed.relay, &stale_bundle, None).await.unwrap();
11574 assert_eq!(readopted.root_epoch, Epoch(0), "re-anchored at the stale epoch");
11575 let follow = follow_rekeys(&bed.relay, &readopted, &crate::db::current_session()).await.unwrap();
11577 assert!(!follow.self_removed, "never a false removal on this build");
11578 let healed = follow.updated.expect("the walk adopts the split rotation");
11579 assert_eq!(healed.root_epoch, Epoch(1));
11580 assert_eq!(healed.control_pk, refounded.control_pk, "the member reads the upgraded plane");
11581 assert_eq!(healed.control_root, None, "a plain member never receives the secret");
11582 let folded = follow_control(&bed.relay, &healed).await;
11584 assert!(folded.is_ok(), "the recovered member folds the split plane: {folded:?}");
11585 }
11586
11587 #[tokio::test]
11588 async fn an_unopenable_blob_at_my_locator_never_concludes_removal() {
11589 let (bed, owner, member) = TestBed::new();
11594 bed.swap_to(&owner);
11595 let community = create_community(&bed.relay, "Garbled", bed.relays.clone(), None).await.unwrap();
11596 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
11597 bed.swap_to(&member);
11598 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
11599
11600 bed.swap_to(&owner);
11603 let new_epoch = Epoch(1);
11604 let new_root = [0x4Du8; 32];
11605 let control_root = [0x4Eu8; 32];
11606 let control_pk = super::super::derive::control_signer_group_key(&control_root, community.id(), new_epoch).pk();
11607 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
11608 let garbled = rekey::RekeyBlob {
11609 locator: rekey::blob_locator(&owner.keys.public_key().to_bytes(), &member.keys.public_key().to_bytes(), RekeyScope::Root, new_epoch),
11610 wrapped: "bm90LWEtcmVhbC1uaXA0NC1wYXlsb2Fk".into(),
11611 };
11612 let own = rekey::build_base_blob(
11613 &crate::signer::ActiveSigner::Keys(owner.keys.clone()),
11614 &owner.keys.public_key().to_bytes(),
11615 &owner.keys.public_key(),
11616 new_epoch,
11617 &new_root,
11618 &control_pk.to_bytes(),
11619 Some(&control_root),
11620 )
11621 .await
11622 .unwrap();
11623 let base_group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
11624 let chunks = rekey::build_rekey_chunks_local(&owner.keys, &base_group, RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[garbled, own], 2_000, None, false).unwrap();
11625 for c in &chunks {
11626 bed.relay.publish(c, &community.relays).await.unwrap();
11627 }
11628
11629 bed.swap_to(&member);
11631 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
11632 assert!(!follow.self_removed, "an unopenable blob at my locator is not an exclusion");
11633 assert!(follow.updated.is_none(), "nothing adopted from an unreadable blob");
11634 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "the community survives");
11635 assert!(follow.wedged, "an unopenable key delivery must be surfaced, not swallowed");
11639 }
11640
11641 #[tokio::test]
11642 async fn a_control_wrap_is_refused_on_a_stale_epoch_or_a_non_deriving_secret() {
11643 let (bed, owner, member) = TestBed::new();
11652 bed.swap_to(&owner);
11653 let community = create_community(&bed.relay, "AdoptGate", bed.relays.clone(), None).await.unwrap();
11654 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
11655 bed.swap_to(&member);
11656 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
11657 assert!(joined.control_root.is_none());
11658
11659 let wrap_for = |epoch: Epoch, secret: &[u8; 32]| {
11661 let ck = nostr_sdk::prelude::nip44::v2::ConversationKey::derive(owner.keys.secret_key(), &member.keys.public_key()).unwrap();
11662 let ct = crate::community::cipher::encrypt_with_random_nonce(&ck, super::rekey::control_wrap_b64(epoch, secret).as_bytes()).unwrap();
11663 base64_simd::STANDARD.encode_to_string(&ct)
11664 };
11665 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.keys.public_key().to_bytes());
11666 let ed_with = |wrap: String| {
11667 let grant = MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![] };
11668 let content = crate::community::v2::roles::grant_content_json_with_wrap(&grant, Some(wrap)).unwrap();
11669 let rumor = control::build_edition_rumor(owner.keys.public_key(), vsk::GRANT, &eid, 1, None, &content, 1_000, None);
11670 control::parse_edition_rumor(&rumor).unwrap()
11671 };
11672
11673 let real = community.control_root.unwrap();
11675 let stale = ed_with(wrap_for(Epoch(joined.root_epoch.0 + 1), &real));
11676 assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&stale)).await, None, "a stale-epoch wrap is refused");
11677
11678 let forged = ed_with(wrap_for(joined.root_epoch, &[0x66u8; 32]));
11680 assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&forged)).await, None, "a non-deriving secret is refused");
11681
11682 let good = ed_with(wrap_for(joined.root_epoch, &real));
11685 assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&good)).await, Some(real), "the true wrap adopts");
11686 }
11687
11688 #[test]
11689 fn the_vault_carries_the_split_and_the_twin_strips_the_secret() {
11690 let owner = Keys::generate();
11694 let g = control::genesis(&owner, control::CommunityMetadata { name: "Vault".into(), ..Default::default() }, 1_000).unwrap();
11695 let c = CommunityV2::from_genesis(&g, "Vault", None, vec!["wss://r".into()], 0);
11696 let jm = join_material(&c);
11697 assert_eq!(jm.control_pk, c.control_pk.map(|p| p.to_hex()));
11698 assert_eq!(jm.control_root, Some(crate::simd::hex::bytes_to_hex_32(&g.control_root)));
11699 let twin = twin_join_material(&c);
11700 assert_eq!(twin.control_pk, jm.control_pk, "members get the address");
11701 assert_eq!(twin.control_root, None, "members never get the secret");
11702 let bundle = material_to_invite(&jm);
11703 assert_eq!(bundle.control_pk, jm.control_pk, "a rehydrating bundle carries the address");
11704 }
11705
11706 #[tokio::test]
11712 async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
11713 let (bed, owner, attacker) = TestBed::new();
11714 bed.swap_to(&owner);
11715 let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
11716 let victim = Keys::generate().public_key();
11717 grant_admin(&bed.relay, &community, &victim).await.unwrap();
11718
11719 let admin_rid = fetch_authority(&bed.relay, &community)
11721 .await
11722 .roles
11723 .roles
11724 .iter()
11725 .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
11726 .unwrap()
11727 .role_id
11728 .clone();
11729 publish_role(
11731 &bed.relay,
11732 &community,
11733 &attacker.keys,
11734 &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
11735 2,
11736 )
11737 .await;
11738
11739 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
11740 assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
11741 assert!(
11742 authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
11743 "the floor advances only to the AUTHORIZED head (owner v1)"
11744 );
11745 assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
11746 }
11747
11748 #[tokio::test]
11749 async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
11750 let (bed, owner, attacker) = TestBed::new();
11751 bed.swap_to(&owner);
11752 let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
11753 let victim = Keys::generate();
11754 grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
11755
11756 publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
11758
11759 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
11760 assert!(
11761 authority.roles.is_admin(&victim.public_key().to_hex()),
11762 "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
11763 );
11764 }
11765
11766 #[tokio::test]
11767 async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
11768 let (bed, owner, attacker) = TestBed::new();
11769 bed.swap_to(&owner);
11770 let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
11771 let victim = Keys::generate().public_key();
11772 grant_admin(&bed.relay, &community, &victim).await.unwrap();
11773
11774 for i in 0u8..6 {
11777 let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
11778 publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11779 }
11780
11781 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
11782 assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
11783 assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
11784 }
11785
11786 fn authority_fingerprint(a: &AuthoritySet) -> String {
11789 let mut roles = a.roles.roles.clone();
11790 roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
11791 let mut grants = a.roles.grants.clone();
11792 for g in &mut grants {
11793 g.role_ids.sort();
11794 }
11795 grants.sort_by(|x, y| x.member.cmp(&y.member));
11796 let banned: Vec<&String> = a.banned.iter().collect();
11797 serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
11798 }
11799
11800 #[tokio::test]
11801 async fn the_v2_authority_fold_is_order_independent() {
11802 let (bed, owner, _a) = TestBed::new();
11808 bed.swap_to(&owner);
11809 let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
11810
11811 let admin1 = Keys::generate().public_key();
11814 let admin2 = Keys::generate().public_key();
11815 grant_admin(&bed.relay, &community, &admin1).await.unwrap();
11816 grant_admin(&bed.relay, &community, &admin2).await.unwrap();
11817 let mod_rid = "5c".repeat(32);
11818 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
11819 let member = Keys::generate().public_key();
11820 publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
11821 let banned_member = Keys::generate().public_key();
11822 publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
11823 set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
11824 let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
11825 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
11826 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
11827
11828 let editions = fetch_control(&bed.relay, &community).await;
11829 let floors = load_floors(&community);
11830 assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
11831
11832 let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
11833
11834 let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
11837 let mut rev = editions.clone();
11838 rev.reverse();
11839 orders.push(rev);
11840 for shift in [1usize, 3, 5, 7] {
11841 let n = editions.len();
11842 orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
11843 }
11844 let mut zip = Vec::with_capacity(editions.len());
11846 let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
11847 while lo <= hi {
11848 zip.push(editions[lo as usize].clone());
11849 if lo != hi {
11850 zip.push(editions[hi as usize].clone());
11851 }
11852 lo += 1;
11853 hi -= 1;
11854 }
11855 orders.push(zip);
11856
11857 for (i, order) in orders.iter().enumerate() {
11858 let got = authority_fingerprint(&fold_authority(&community, order, &floors));
11859 assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
11860 }
11861 assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
11864 assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
11865 }
11866
11867 struct FetchErrors(MemoryRelay);
11869 #[async_trait::async_trait]
11870 impl crate::community::transport::Transport for FetchErrors {
11871 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
11872 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
11873 self.0.publish(e, r).await
11874 }
11875 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
11876 Err("relay down".to_string())
11877 }
11878 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
11879 self.0.publish_durable(e, r).await
11880 }
11881 }
11882
11883 #[tokio::test]
11884 async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
11885 let (bed, owner, victim) = TestBed::new();
11886 bed.swap_to(&owner);
11887 let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
11888 let victim_hex = victim.keys.public_key().to_hex();
11889 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11891 crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
11892
11893 let down = FetchErrors(MemoryRelay::new());
11896 let view = fetch_authority(&down, &community).await;
11897 assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
11898 }
11899
11900 #[tokio::test]
11901 async fn the_fold_pages_past_a_full_window_to_find_the_roster_it_tracks() {
11902 let (bed, owner, _m) = TestBed::new();
11911 bed.swap_to(&owner);
11912 let community = create_community(&bed.relay, "DeepFold", bed.relays.clone(), None).await.unwrap();
11913 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11914 let rid = "e7".repeat(32);
11915 let admin = Keys::generate();
11916
11917 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
11919 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
11920 follow_control(&bed.relay, &community).await.unwrap();
11921 assert!(
11922 crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
11923 "baseline: the admin folds while its editions are still in reach",
11924 );
11925
11926 for i in 0..(FOLLOW_PAGE + 50) {
11928 publish_channel_edition_at(
11929 &bed.relay, &community, &owner.keys,
11930 &ChannelId(crate::simd::hex::hex_to_bytes_32(&format!("{:064x}", i + 1))),
11931 &format!("ch{i}"), 1, 2_000 + i as u64,
11932 ).await;
11933 }
11934
11935 let grant_eid = crate::simd::hex::bytes_to_hex_32(
11939 &crate::community::v2::derive::grant_locator(community.id(), &admin.public_key().to_bytes()),
11940 );
11941 crate::db::community::delete_edition_head(&cid_hex, &grant_eid).unwrap();
11942 assert!(
11943 crate::db::community::get_edition_head(&cid_hex, &grant_eid).unwrap().is_none(),
11944 "precondition: the floor is gone",
11945 );
11946
11947 follow_control(&bed.relay, &community).await.unwrap();
11948
11949 assert!(
11950 crate::db::community::get_edition_head(&cid_hex, &grant_eid).unwrap().is_some(),
11951 "the fold MUST page past the newest window and reach the grant that defines the \
11952 admin list — stopping at the first coherent-looking page is how a community \
11953 becomes unable to re-derive its own roster",
11954 );
11955 assert!(
11956 crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
11957 "and the admin survives",
11958 );
11959 }
11960
11961 #[tokio::test]
11962 async fn a_grant_naming_a_role_its_own_holder_created_still_folds() {
11963 let (bed, owner, _m) = TestBed::new();
11975 bed.swap_to(&owner);
11976 let community = create_community(&bed.relay, "GrantCycle", bed.relays.clone(), None).await.unwrap();
11977 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11978 let admin = Keys::generate();
11979 let member = Keys::generate();
11980
11981 let owner_role = "aa".repeat(32);
11983 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&owner_role, Permissions::ADMIN_ALL), 1).await;
11984 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![owner_role.clone()], 1).await;
11985 follow_control(&bed.relay, &community).await.unwrap();
11986 assert!(
11987 crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
11988 "the admin is promoted",
11989 );
11990
11991 let admin_made = "bb".repeat(32);
11993 let mut made = admin_role(&admin_made, Permissions::BAN);
11994 made.position = 5;
11995 publish_role(&bed.relay, &community, &admin, &made, 1).await;
11996
11997 follow_control(&bed.relay, &community).await.unwrap();
11998 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11999 assert!(
12000 roster.role(&admin_made).is_some(),
12001 "an admin-authored role folds while its author's grant is resolvable",
12002 );
12003 assert!(
12004 roster.is_admin(&admin.public_key().to_hex()),
12005 "and the admin keeps their own standing — a cycle here empties the roster",
12006 );
12007
12008 publish_grant(
12013 &bed.relay, &community, &owner.keys, &member.public_key(),
12014 vec![admin_made.clone(), "cc".repeat(32)], 1,
12015 ).await;
12016 follow_control(&bed.relay, &community).await.unwrap();
12017 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12018 let mg = roster.grants.iter().find(|g| g.member == member.public_key().to_hex());
12019 assert!(
12020 mg.is_some_and(|g| g.role_ids.contains(&admin_made)),
12021 "the resolvable half of the grant is honored",
12022 );
12023 assert!(
12024 mg.is_some_and(|g| !g.role_ids.iter().any(|r| r == &"cc".repeat(32))),
12025 "and the unresolvable half is NOT — partial resolution only ever grants less",
12026 );
12027 assert!(
12028 roster.is_admin(&admin.public_key().to_hex()),
12029 "the rest of the roster survives it",
12030 );
12031 }
12032
12033 #[tokio::test]
12034 async fn a_cold_device_pages_past_the_first_window_to_find_an_older_grant() {
12035 let (bed, owner, _m) = TestBed::new();
12041 bed.swap_to(&owner);
12042 let community = create_community(&bed.relay, "ColdPage", bed.relays.clone(), None).await.unwrap();
12043 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12044
12045 let admin = Keys::generate();
12047 let rid = "e5".repeat(32);
12048 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12049 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
12050
12051 for v in 2..(FOLLOW_PAGE as u64 + 2) {
12053 publish_filler_edition(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), v, 1_000 + v).await;
12054 }
12055
12056 follow_control(&bed.relay, &community).await.unwrap();
12057
12058 assert!(
12059 crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12060 "a cold device must page PAST the first window — stopping there caches a \
12061 partial authority as its own baseline and drops every older grant",
12062 );
12063 }
12064
12065 #[tokio::test]
12066 async fn a_truncated_cold_fold_banks_nothing_at_all() {
12067 let (bed, owner, _m) = TestBed::new();
12074 bed.swap_to(&owner);
12075 let community = create_community(&bed.relay, "Wall", bed.relays.clone(), None).await.unwrap();
12076 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12077
12078 let admin = Keys::generate();
12079 let rid = "e5".repeat(32);
12080 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12081 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
12082 for v in 2..(FOLLOW_PAGE as u64 + 2) {
12084 publish_filler_edition(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), v, 1_000).await;
12085 }
12086
12087 follow_control(&bed.relay, &community).await.unwrap();
12088
12089 assert_eq!(
12090 crate::db::community::get_community_roles_at(&cid_hex).unwrap(),
12091 0,
12092 "a truncated fold must bank NO baseline — a stored partial roster outranks \
12093 the complete fold that follows it and can never be replaced",
12094 );
12095 }
12096
12097 #[tokio::test]
12098 async fn an_older_window_never_overwrites_a_newer_cached_roster() {
12099 let (bed, owner, _m) = TestBed::new();
12103 bed.swap_to(&owner);
12104 let community = create_community(&bed.relay, "Monotone", bed.relays.clone(), None).await.unwrap();
12105 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12106
12107 let admin = Keys::generate();
12108 let rid = "e5".repeat(32);
12109 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12110 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
12111 follow_control(&bed.relay, &community).await.unwrap();
12112 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()));
12113
12114 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12116 crate::db::community::set_community_roles(&cid_hex, &roster, i64::MAX).unwrap();
12117
12118 follow_control(&bed.relay, &community).await.unwrap();
12119 assert!(
12120 crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12121 "an older window must not overwrite a newer cached roster",
12122 );
12123 assert_eq!(
12124 crate::db::community::get_community_roles_at(&cid_hex).unwrap(),
12125 i64::MAX,
12126 "and the newer provenance stamp survives — otherwise the next fold downgrades freely",
12127 );
12128 }
12129
12130 struct SlowMajority<'a>(&'a MemoryRelay, u64);
12134 #[async_trait::async_trait]
12135 impl Transport for SlowMajority<'_> {
12136 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
12137 self.0.publish(e, r).await
12138 }
12139 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
12140 let out = self.0.fetch(query, relays).await?;
12141 if query.evidence == crate::community::transport::Evidence::Full {
12142 return Ok(out);
12143 }
12144 Ok(out.into_iter().filter(|e| e.created_at.as_secs() > self.1).collect())
12145 }
12146 async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
12147 self.0.fetch_plane(plane, query, relays).await
12148 }
12149 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
12150 self.0.publish_durable(e, r).await
12151 }
12152 }
12153
12154 #[tokio::test]
12155 async fn a_live_link_a_quorum_cannot_see_still_reads_public() {
12156 let (_tmp, _guard, _owner) = init_test_db();
12162 let relay = MemoryRelay::new();
12163 let community = create_community(&relay, "QuorumBlind", vec!["wss://r".into()], None).await.unwrap();
12164 mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
12165 assert!(community_is_public(&relay, &community).await, "the link is live on the plane");
12166
12167 let slow = SlowMajority(&relay, u64::MAX);
12170 assert!(
12171 community_is_public(&slow, &community).await,
12172 "a live link only a minority relay serves must still read Public — reading \
12173 Private here refounds a Public community on ban, severing nothing",
12174 );
12175 }
12176
12177 #[tokio::test]
12178 async fn a_quorum_that_cannot_see_a_stored_entity_escalates_to_full() {
12179 let (bed, owner, _m) = TestBed::new();
12184 bed.swap_to(&owner);
12185 let community = create_community(&bed.relay, "Escalate", bed.relays.clone(), None).await.unwrap();
12186 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12187
12188 let admin = Keys::generate();
12189 let rid = "e5".repeat(32);
12190 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12191 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
12192
12193 follow_control(&bed.relay, &community).await.unwrap();
12195 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()));
12196
12197 let admin2 = Keys::generate();
12201 publish_grant(&bed.relay, &community, &owner.keys, &admin2.public_key(), vec![rid], 1).await;
12202
12203 let slow = SlowMajority(&bed.relay, 5_000);
12204 follow_control(&slow, &community).await.unwrap();
12205
12206 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12207 assert!(
12208 roster.is_admin(&admin.public_key().to_hex()),
12209 "the first admin survives — the retain is doing its job",
12210 );
12211 assert!(
12212 roster.is_admin(&admin2.public_key().to_hex()),
12213 "and the roster still ADVANCES: a Quorum read that could not account for a \
12214 stored entity must re-read at Full, or the cache freezes holding the only \
12215 surviving copy of the roster and no promotion ever lands again",
12216 );
12217 }
12218
12219 #[tokio::test]
12220 async fn one_gapped_entity_does_not_freeze_the_rest_of_the_roster() {
12221 let (bed, owner, _m) = TestBed::new();
12227 bed.swap_to(&owner);
12228 let community = create_community(&bed.relay, "OneGap", bed.relays.clone(), None).await.unwrap();
12229 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12230
12231 let admin = Keys::generate();
12233 let rid = "e5".repeat(32);
12234 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12235 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
12236
12237 let rid2 = "c7".repeat(32);
12242 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid2, Permissions::BAN), 5).await;
12243 crate::db::community::set_edition_head_at_epoch(&cid_hex, &rid2, 3, &[9u8; 32], &[9u8; 32], community.root_epoch.0).unwrap();
12244
12245 follow_control(&bed.relay, &community).await.unwrap();
12246
12247 assert!(
12248 crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12249 "a hole in ONE entity must not withhold every other entity's authority — that is \
12250 how a community that folds correctly still shows an empty roster",
12251 );
12252 }
12253
12254 #[tokio::test]
12255 async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
12256 let (bed, owner, _m) = TestBed::new();
12257 bed.swap_to(&owner);
12258 let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
12259 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12260 let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
12261 let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
12262
12263 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
12265 publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
12266 publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
12267 follow_control(&bed.relay, &community).await.unwrap().updated;
12268 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
12269
12270 let relay2 = MemoryRelay::new();
12274 publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
12275 follow_control(&relay2, &community).await.unwrap().updated;
12276 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12277 assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
12278 }
12279
12280 #[tokio::test]
12281 async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
12282 let (_tmp, _guard, owner) = init_test_db();
12287 let relay = MemoryRelay::new();
12288 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
12289 let admin = Keys::generate();
12290 let rid = "a7".repeat(32);
12291 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
12292 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
12293
12294 let group = control::ControlPlane::of(&community).write_group().unwrap();
12296 let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
12297 let rumor = control::build_edition_rumor(
12298 admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
12299 head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
12300 &serde_json::to_string(&meta).unwrap(), 1_000, None,
12301 );
12302 let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
12303 relay.publish(&wrap, &community.relays).await.unwrap();
12304
12305 let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
12306 let victim = Keys::generate().public_key().to_hex();
12307 let ban_rumor = control::build_edition_rumor(
12308 admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
12309 &serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
12310 );
12311 let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
12312 relay.publish(&ban_wrap, &community.relays).await.unwrap();
12313
12314 let updated = follow_control(&relay, &community).await.unwrap().updated;
12315 assert!(
12316 updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
12317 "an uncited metadata edit must not be honored",
12318 );
12319 let authority = fetch_authority(&relay, &community).await;
12320 assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
12321 }
12328
12329 #[tokio::test]
12330 async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
12331 let (_tmp, _guard, owner) = init_test_db();
12335 let relay = MemoryRelay::new();
12336 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
12337 let admin = Keys::generate();
12338 let rid = "a1".repeat(32);
12339 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
12340 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
12341 publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
12342
12343 let updated = follow_control(&relay, &community).await.unwrap().updated.expect("admin edit authorized");
12344 assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
12345
12346 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
12348 let _ = follow_control(&relay, &community).await.unwrap().updated;
12349 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12350 assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
12351 }
12352
12353 #[tokio::test]
12354 async fn a_roleless_member_cannot_edit_metadata() {
12355 let (_tmp, _guard, _owner) = init_test_db();
12356 let relay = MemoryRelay::new();
12357 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
12358 let stranger = Keys::generate();
12359 publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
12360 assert!(
12361 follow_control(&relay, &community).await.unwrap().updated.is_none(),
12362 "a roleless member's metadata edit never folds"
12363 );
12364 }
12365
12366 #[tokio::test]
12367 async fn a_self_signed_grant_is_not_authority() {
12368 let (_tmp, _guard, _owner) = init_test_db();
12372 let relay = MemoryRelay::new();
12373 let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
12374 let rogue = Keys::generate();
12375 let rid = "b2".repeat(32);
12376 publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
12377 publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
12378 publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
12379 assert!(
12380 follow_control(&relay, &community).await.unwrap().updated.is_none(),
12381 "a self-signed grant confers no authority"
12382 );
12383 }
12384
12385 #[tokio::test]
12386 async fn the_banlist_is_enforced_only_from_a_ban_holder() {
12387 let (_tmp, _guard, owner) = init_test_db();
12388 let relay = MemoryRelay::new();
12389 let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
12390 let target = "cc".repeat(32);
12391
12392 let rogue = Keys::generate();
12394 publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
12395 let floors = load_floors(&community);
12396 let editions = fetch_control(&relay, &community).await;
12397 let authority = fold_authority(&community, &editions, &floors);
12398 assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
12399
12400 publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
12402 let editions = fetch_control(&relay, &community).await;
12403 let authority = fold_authority(&community, &editions, &floors);
12404 assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
12405 }
12406
12407 #[tokio::test]
12408 async fn a_banned_admin_loses_all_authority() {
12409 let (_tmp, _guard, owner) = init_test_db();
12412 let relay = MemoryRelay::new();
12413 let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
12414 let admin = Keys::generate();
12415 let rid = "e5".repeat(32);
12416 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
12417 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
12418 publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
12420
12421 assert!(
12422 follow_control(&relay, &community).await.unwrap().updated.is_none(),
12423 "a banned admin's edit is dropped even with an unstripped grant"
12424 );
12425 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
12426 assert!(authority.banned.contains(&admin.public_key().to_hex()));
12427 assert!(
12428 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
12429 "a banned admin holds no bit"
12430 );
12431 }
12432
12433 #[tokio::test]
12434 async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
12435 let (_tmp, _guard, owner) = init_test_db();
12439 let relay = MemoryRelay::new();
12440 let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
12441 let admin = Keys::generate();
12442 let moder = Keys::generate();
12443 let stranger = Keys::generate();
12444 let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
12445 publish_role(&relay, &community, &owner, &Role { role_id: admin_rid.clone(), name: "Admin".into(), position: 1, permissions: Permissions(Permissions::ADMIN_ALL), scope: RoleScope::Server, color: 0 }, 1).await;
12446 publish_role(&relay, &community, &owner, &Role { role_id: mod_rid.clone(), name: "Mod".into(), position: 2, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
12447 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
12448 publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
12449 publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
12450
12451 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
12452 assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
12453 assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
12454 assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
12455 }
12456
12457 #[tokio::test]
12458 async fn an_unauthorized_higher_banlist_cannot_unban() {
12459 let (_tmp, _guard, owner) = init_test_db();
12463 let relay = MemoryRelay::new();
12464 let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
12465 let target = "cc".repeat(32);
12466 publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
12467 follow_control(&relay, &community).await.unwrap().updated; let rogue = Keys::generate();
12470 publish_banlist(&relay, &community, &rogue, &[], 2).await; let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
12472 assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
12473 }
12474
12475 #[tokio::test]
12476 async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
12477 let (_tmp, _guard, _owner) = init_test_db();
12480 let relay = MemoryRelay::new();
12481 let relays = vec!["wss://r".to_string()];
12482 let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
12483 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
12484 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
12485
12486 let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
12487 assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
12488 assert_eq!(rehydrated[0].id().0, community.id().0);
12489 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
12490 }
12491
12492 #[tokio::test]
12493 async fn a_retried_leave_tombstone_cannot_bury_a_rejoin_made_meanwhile() {
12494 let (_tmp, _guard, _owner) = init_test_db();
12495 let relay = MemoryRelay::new();
12496 let relays = vec!["wss://r".to_string()];
12497 let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
12498 let cid = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12499
12500 let left_at = now_ms() - 1000;
12502 tombstone_community_list(&relay, community.id(), &relays, left_at).await.unwrap();
12503 republish_community_list(&relay, Some(community.id())).await.unwrap();
12505 assert!(fetch_fragments(&relay, &relays).await.unwrap().unwrap().list.is_live(&cid));
12506
12507 tombstone_community_list(&relay, community.id(), &relays, left_at).await.unwrap();
12509 let set = fetch_fragments(&relay, &relays).await.unwrap().unwrap();
12510 assert!(set.list.is_live(&cid), "a retry re-stamping `now` would have buried the rejoin");
12511 }
12512
12513 #[tokio::test]
12514 async fn a_boot_with_no_fragments_seeds_the_list_from_local_state() {
12515 let (_tmp, _guard, _owner) = init_test_db();
12516 let relay = MemoryRelay::new();
12517 let relays = vec!["wss://r".to_string()];
12518 let community = create_community(&relay, "Seeded", relays.clone(), None).await.unwrap();
12519
12520 let bare = MemoryRelay::new();
12523 sync_community_list(&bare, &relays).await.unwrap();
12524 let set = fetch_fragments(&bare, &relays).await.unwrap().expect("boot seeded the List");
12525 let live = set.list.live_entries();
12526 assert_eq!(live.len(), 1);
12527 assert_eq!(live[0].community_id, crate::simd::hex::bytes_to_hex_32(&community.id().0));
12528
12529 let empty = MemoryRelay::new();
12532 sync_community_list(&empty, &relays).await.unwrap();
12533 assert_eq!(empty.stored_count(), 0, "the seed runs once");
12534 }
12535
12536 #[tokio::test]
12537 async fn a_dissolved_invite_retires_on_accept_and_tombstones_the_list() {
12538 let (bed, owner, member) = TestBed::new();
12542 bed.swap_to(&owner);
12543 let community = create_community(&bed.relay, "Grave", bed.relays.clone(), None).await.unwrap();
12544 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
12545 dissolve_community(&bed.relay, &community).await.unwrap();
12546
12547 bed.swap_to(&member);
12548 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12549 crate::db::community::save_pending_invite(&cid_hex, &bundle, "inviter", i64::MAX).unwrap();
12550 let err = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap_err();
12551 assert_eq!(err, ERR_DISSOLVED, "the grave refusal is the sentinel the boundary matches");
12552
12553 retire_dead_invite(&bed.relay, &cid_hex, &bed.relays).await;
12554 assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_none(), "the dead invite leaves this device");
12555 let set = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("the tombstone published");
12556 assert!(set.list.tombstones.iter().any(|t| t.community_id == cid_hex), "and siblings get the §8 tombstone");
12557 assert!(!set.list.is_live(&cid_hex));
12558 }
12559
12560 #[tokio::test]
12561 async fn an_owner_dissolve_tombstones_the_list_for_sibling_devices() {
12562 let (bed, owner, _member) = TestBed::new();
12567 bed.swap_to(&owner);
12568 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
12569 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12570 let before = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("create published the List");
12571 assert!(before.list.is_live(&cid_hex), "the membership is live before the dissolve");
12572
12573 dissolve_community(&bed.relay, &community).await.unwrap();
12574
12575 let set = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("the List outlives the dissolve");
12576 assert!(
12577 set.list.tombstones.iter().any(|t| t.community_id == cid_hex),
12578 "the dissolve published the §8 tombstone alongside the grave"
12579 );
12580 assert!(!set.list.is_live(&cid_hex), "sibling devices tear the husk down on their next list sync");
12581 }
12582
12583 #[tokio::test]
12584 async fn a_sibling_devices_list_tombstone_purges_the_parked_invite() {
12585 let (_tmp, _guard, _owner) = init_test_db();
12589 let relay = MemoryRelay::new();
12590 let relays = vec!["wss://r".to_string()];
12591 let cid = crate::community::CommunityId([0x5A; 32]);
12592 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
12593
12594 tombstone_community_list(&relay, &cid, &relays, now_ms() - 600_000).await.unwrap();
12596 crate::db::community::save_pending_invite(&cid_hex, "{}", "inviter", i64::MAX).unwrap();
12597 sync_community_list(&relay, &relays).await.unwrap();
12598 assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_some(), "a re-invite supersedes a past leave");
12599
12600 tombstone_community_list(&relay, &cid, &relays, now_ms() + 10_000).await.unwrap();
12602 sync_community_list(&relay, &relays).await.unwrap();
12603 assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_none(), "the sibling's removal clears the parked copy");
12604 }
12605
12606 #[tokio::test]
12607 async fn an_unchanged_republish_rewrites_no_fragment() {
12608 let (_tmp, _guard, _owner) = init_test_db();
12612 let relay = MemoryRelay::new();
12613 let relays = vec!["wss://r".to_string()];
12614 create_community(&relay, "Stable", relays.clone(), None).await.unwrap();
12615 let before = fetch_fragments(&relay, &relays).await.unwrap().unwrap().created_at;
12616
12617 republish_community_list(&relay, None).await.unwrap();
12618 let after = fetch_fragments(&relay, &relays).await.unwrap().unwrap().created_at;
12619 assert_eq!(before, after, "a byte-identical fragment must not republish");
12620 }
12621
12622 #[tokio::test]
12623 async fn a_frags_tie_at_equal_age_resolves_to_the_larger_count() {
12624 let (_tmp, _guard, owner) = init_test_db();
12628 let relay = MemoryRelay::new();
12629 let relays = vec!["wss://r".to_string()];
12630 let at = now_ms() / 1000;
12631 let two = super::super::list_frag::FragList { frags: 2, entries: vec![], tombstones: vec![], extra: Default::default() };
12632 let three = super::super::list_frag::FragList { frags: 3, entries: vec![], tombstones: vec![], extra: Default::default() };
12633 for (frag, index) in [(&three, 0usize), (&two, 1usize)] {
12636 let e = super::super::list_frag::build_fragment_event_keys(&owner, frag, index, at).unwrap();
12637 relay.publish(&e, &relays).await.unwrap();
12638 }
12639 let set = fetch_fragments(&relay, &relays).await.unwrap().unwrap();
12640 assert_eq!(set.declared, 3, "an age tie resolves to the larger frags");
12641 assert!(!set.is_complete(), "index 2 is unread, so the set refuses to write");
12642 }
12643
12644 struct HalfDeaf<'a>(&'a MemoryRelay, String);
12647 #[async_trait::async_trait]
12648 impl Transport for HalfDeaf<'_> {
12649 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
12650 self.0.publish(e, r).await
12651 }
12652 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
12653 if relays.len() == 1 && relays[0] == self.1 {
12654 return Err("relay did not answer the fetch".to_string());
12655 }
12656 self.0.fetch(query, relays).await
12657 }
12658 async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
12659 self.0.fetch_plane(plane, query, relays).await
12660 }
12661 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
12662 self.0.publish_durable(e, r).await
12663 }
12664 }
12665
12666 #[tokio::test]
12667 async fn an_unconfirmed_empty_read_never_seeds() {
12668 let (_tmp, _guard, _owner) = init_test_db();
12674 let relay = MemoryRelay::new();
12675 let relays = vec!["wss://r".to_string(), "wss://dead".to_string()];
12676 let community = create_community(&relay, "Gated", relays.clone(), None).await.unwrap();
12677 let cid = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12678
12679 let bare = MemoryRelay::new();
12680 let half_deaf = HalfDeaf(&bare, "wss://dead".to_string());
12681 sync_community_list(&half_deaf, &relays).await.unwrap();
12682 assert_eq!(bare.stored_count(), 0, "a failed read must never seed");
12683 assert!(
12684 crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).unwrap().is_none(),
12685 "a deferred seed must not latch"
12686 );
12687
12688 sync_community_list(&bare, &relays).await.unwrap();
12690 let set = fetch_fragments(&bare, &relays).await.unwrap().expect("the confirmed-empty boot seeds");
12691 assert!(set.list.is_live(&cid));
12692 assert!(crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).unwrap().is_some());
12693 }
12694
12695 #[tokio::test]
12696 async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
12697 let (_tmp, _guard, _owner) = init_test_db();
12698 let relay = MemoryRelay::new();
12699 let relays = vec!["wss://r".to_string()];
12700 let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
12701 leave_community(&relay, &community).await.unwrap(); let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
12704 assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
12705 }
12706
12707 #[tokio::test]
12708 async fn accepting_the_same_bundle_twice_is_idempotent() {
12709 let (bed, owner, member) = TestBed::new();
12713 bed.swap_to(&owner);
12714 let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
12715 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
12716 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12717 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
12718
12719 bed.swap_to(&member);
12720 let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
12721 let channels_after_first = first.channels.len();
12722 let root_after_first = first.community_root;
12723
12724 let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
12726 assert_eq!(second.id().0, first.id().0, "same community_id");
12727 assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
12728 assert_eq!(second.community_root, root_after_first, "root unchanged");
12729
12730 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12732 assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
12733 assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
12734 }
12735
12736 #[tokio::test]
12737 async fn a_fresh_joiner_can_authorize_a_moderators_hide_before_any_follow() {
12738 let (bed, owner, member) = TestBed::new();
12745 let admin = Keys::generate();
12746 bed.swap_to(&owner);
12747 let community = create_community(&bed.relay, "Moderated", bed.relays.clone(), None).await.unwrap();
12748 grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
12749 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
12750
12751 bed.swap_to(&member);
12754 let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
12755 let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
12756 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
12757
12758 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12759 assert!(!roster.grants.is_empty(), "the join persisted the roster it already folded");
12760 let owner_hex = joined.owner().unwrap().to_hex();
12761 let victim = Keys::generate().public_key().to_hex();
12762 assert!(
12763 crate::community::moderation::can_hide(Some(&owner_hex), &roster, &admin.public_key().to_hex(), &victim),
12764 "the admin's hides are honorable on the FIRST history ingest, not after a follow"
12765 );
12766 let grant_hex = crate::simd::hex::bytes_to_hex_32(&crate::community::v2::derive::grant_locator(
12769 joined.id(),
12770 &admin.public_key().to_bytes(),
12771 ));
12772 assert!(
12773 crate::db::community::get_edition_head(&cid_hex, &grant_hex).unwrap().is_some(),
12774 "the admin-signed grant head survived the join"
12775 );
12776 }
12777
12778 #[tokio::test]
12779 async fn a_severed_member_can_be_unbanned_and_re_admitted() {
12780 let (bed, owner, member) = TestBed::new();
12785 bed.swap_to(&owner);
12786 let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
12787 let general = community.channels[0].id;
12788 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
12789 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
12790
12791 bed.swap_to(&member);
12792 let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
12793 let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
12794 assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
12795
12796 bed.swap_to(&owner);
12798 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
12799 grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
12800 community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
12801 assert_eq!(community.root_epoch, Epoch(1));
12802 send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
12803
12804 bed.swap_to(&member);
12806 let session = crate::db::current_session();
12807 assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
12808
12809 bed.swap_to(&owner);
12813 set_banlist(&bed.relay, &community, &[]).await.unwrap();
12814 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12815 assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
12816 let fresh_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
12817
12818 bed.swap_to(&member);
12820 let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
12821 assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
12822 assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
12823 let seen = texts_in(&bed.relay, &rejoined, &general).await;
12824 assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
12825 send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
12826
12827 bed.swap_to(&owner);
12828 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12829 assert!(
12830 texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
12831 "the re-admitted member converses again at the new epoch"
12832 );
12833 let members = memberlist(&bed.relay, &community).await.unwrap();
12835 assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
12836 }
12837
12838 #[tokio::test]
12839 async fn dissolution_blocks_a_join() {
12840 let (bed, owner, member) = TestBed::new();
12843 bed.swap_to(&owner);
12844 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
12845 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
12846 let bundle_json = serde_json::to_string(&bundle).unwrap();
12847 dissolve_community(&bed.relay, &community).await.unwrap();
12848 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
12849
12850 bed.swap_to(&member);
12851 let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
12852 assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
12853 }
12854
12855 #[tokio::test]
12856 async fn dissolution_seals_writes_but_not_reads() {
12857 let (bed, owner, _member) = TestBed::new();
12860 bed.swap_to(&owner);
12861 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
12862 let general = community.channels[0].id;
12863 send_message(&bed.relay, &community, &general, "before the end").await.unwrap();
12864
12865 dissolve_community(&bed.relay, &community).await.unwrap();
12866 let sealed = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12867
12868 for err in [
12869 send_message(&bed.relay, &sealed, &general, "after the end").await.unwrap_err(),
12870 send_reaction(&bed.relay, &sealed, &general, &"a".repeat(64), &"b".repeat(64), crate::community::v2::kind::MESSAGE, "+", None)
12871 .await
12872 .unwrap_err(),
12873 send_edit(&bed.relay, &sealed, &general, &"a".repeat(64), "revised").await.unwrap_err(),
12874 ] {
12875 assert!(err.contains("dissolved"), "every write is refused, got: {err}");
12876 }
12877 assert!(
12878 texts_in(&bed.relay, &sealed, &general).await.contains(&"before the end".to_string()),
12879 "but the history still reads"
12880 );
12881 }
12882
12883 #[tokio::test]
12884 async fn only_the_owner_can_dissolve() {
12885 let (bed, owner, member) = TestBed::new();
12886 bed.swap_to(&owner);
12887 let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
12888 bed.swap_to(&member);
12889 assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
12890 assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
12891 }
12892
12893 #[tokio::test]
12894 async fn a_foreign_tombstone_is_not_death() {
12895 let (_tmp, _guard, _owner) = init_test_db();
12898 let relay = MemoryRelay::new();
12899 let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
12900 let rogue = Keys::generate();
12901 let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
12902 let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
12903 relay.publish(&wrap, &community.relays).await.unwrap();
12904 assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
12905 }
12906
12907 #[tokio::test]
12908 async fn a_public_channel_reads_history_across_a_refounding() {
12909 let (_tmp, _guard, _owner) = init_test_db();
12913 let relay = MemoryRelay::new();
12914 let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
12915 let general = community.channels[0].id;
12916 send_message(&relay, &community, &general, "before the refounding").await.unwrap();
12917
12918 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
12919 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
12920 send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
12921
12922 let open = texts_in(&relay, &refounded, &general).await;
12923 assert!(open.contains(&"after the refounding".to_string()), "opening reads the live epoch");
12924 assert!(
12925 !open.contains(&"before the refounding".to_string()),
12926 "and NOT the rotated-past one — that plane is the back-paging cursor's job"
12927 );
12928 let paged = all_texts_in(&relay, &refounded, &general).await;
12929 assert!(paged.contains(&"before the refounding".to_string()), "the epoch-0 message stays reachable by paging");
12930 assert!(paged.contains(&"after the refounding".to_string()), "alongside the epoch-1 one");
12931 }
12932
12933 #[tokio::test]
12934 async fn refounding_aborts_when_control_state_is_withheld() {
12935 let (_tmp, _guard, owner) = init_test_db();
12939 let relay = MemoryRelay::new();
12940 let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
12941 publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
12942 follow_control(&relay, &community).await.unwrap().updated; let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12947 moved.relays = vec!["wss://empty".into()];
12948 crate::db::community::save_community_v2(&moved).unwrap();
12949
12950 let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
12951 assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
12952 assert_eq!(
12953 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
12954 Epoch(0),
12955 "the epoch did NOT advance (zero published state)"
12956 );
12957 }
12958
12959 #[tokio::test]
12960 async fn refounding_rolls_the_root_and_severs_a_removed_member() {
12961 let (bed, owner, member) = TestBed::new();
12964 bed.swap_to(&owner);
12965 let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
12966 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
12967 let bundle_json = serde_json::to_string(&bundle).unwrap();
12968 bed.swap_to(&member);
12969 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
12970
12971 bed.swap_to(&owner);
12972 let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
12973 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
12974 assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
12975 assert_eq!(
12977 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
12978 Epoch(1),
12979 "the owner committed the new epoch"
12980 );
12981
12982 bed.swap_to(&member);
12986 let session = crate::db::current_session();
12987 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
12988 assert!(follow.self_removed, "the removed member is cut by the re-founding");
12989 }
12990
12991 #[tokio::test]
12992 async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
12993 let (bed, owner, member) = TestBed::new();
12998 bed.swap_to(&owner);
12999 let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
13000 let rid = "b0".repeat(32);
13001 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13002 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
13003 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13004 let bundle_json = serde_json::to_string(&bundle).unwrap();
13005 bed.swap_to(&member);
13006 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13007 let _ = follow_control(&bed.relay, &joined).await;
13010 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13011 assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
13013 assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
13015 }
13016
13017 #[tokio::test]
13018 async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
13019 let (bed, owner, me) = TestBed::new();
13024 let admin = Keys::generate();
13025 bed.swap_to(&owner);
13026 let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
13027 let rid = "b0".repeat(32);
13028 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13029 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
13030
13031 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13033 let bundle_json = serde_json::to_string(&bundle).unwrap();
13034 bed.swap_to(&me);
13035 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13036 let _ = follow_control(&bed.relay, &joined).await;
13037 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13038
13039 let new_root = [0xC7; 32];
13042 publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13043
13044 let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
13045 .expect("an authorized admin's Refounding is adopted");
13046 assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
13047 assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
13048 }
13049
13050 #[tokio::test]
13051 async fn adopting_a_severing_rotation_revokes_my_own_links_instead_of_refreshing_them() {
13052 let (bed, owner, me) = TestBed::new();
13058 bed.swap_to(&owner);
13059 let community = create_community(&bed.relay, "SeverHeal", bed.relays.clone(), None).await.unwrap();
13060 let rid = "b1".repeat(32);
13061 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13062 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13063
13064 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13065 let bundle_json = serde_json::to_string(&bundle).unwrap();
13066 bed.swap_to(&me);
13067 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13068 let _ = follow_control(&bed.relay, &joined).await;
13069 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13070
13071 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13072 assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok(), "my link is live before the raid");
13073
13074 let new_root = [0xD5; 32];
13076 publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13077
13078 let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
13079 .expect("the owner's severing Refounding is adopted");
13080 assert_eq!(updated.root_epoch, Epoch(1), "I still advance to the new epoch");
13081
13082 let err = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err();
13083 assert!(err.contains("revoked"), "my link must be a grave, not a refreshed door: {err}");
13084 assert!(
13085 crate::db::community::list_public_invites(&crate::simd::hex::bytes_to_hex_32(&updated.id().0)).unwrap().is_empty(),
13086 "and the local record goes with it",
13087 );
13088 }
13089
13090 #[tokio::test(flavor = "multi_thread")]
13091 async fn a_severing_adopt_never_deadlocks_the_last_link_creator() {
13092 let (bed, owner, me) = TestBed::new();
13099 bed.swap_to(&owner);
13100 let community = create_community(&bed.relay, "NoDeadlock", bed.relays.clone(), None).await.unwrap();
13101 let rid = "b2".repeat(32);
13102 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13103 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13104 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13105 let bundle_json = serde_json::to_string(&bundle).unwrap();
13106 bed.swap_to(&me);
13107 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13108 let _ = follow_control(&bed.relay, &joined).await;
13109 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13110 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13113 assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok());
13114
13115 let new_root = [0xD6; 32];
13116 publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13117
13118 let followed = tokio::time::timeout(
13119 std::time::Duration::from_secs(20),
13120 follow_rekeys(&bed.relay, &joined, &crate::db::current_session()),
13121 )
13122 .await
13123 .expect("the severing follow must not deadlock on follow_lock")
13124 .unwrap();
13125 assert_eq!(followed.updated.expect("adopted").root_epoch, Epoch(1));
13126 }
13127
13128 #[tokio::test(flavor = "multi_thread")]
13129 async fn a_severing_adopt_spares_a_link_minted_after_the_raid() {
13130 let (bed, owner, me) = TestBed::new();
13134 bed.swap_to(&owner);
13135 let community = create_community(&bed.relay, "FreshDoor", bed.relays.clone(), None).await.unwrap();
13136 let rid = "b3".repeat(32);
13137 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13138 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13139 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13140 let bundle_json = serde_json::to_string(&bundle).unwrap();
13141 bed.swap_to(&me);
13142 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13143 let _ = follow_control(&bed.relay, &joined).await;
13144 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13145 let raid_era = mint_public_link(&bed.relay, &joined, "https://old", None, None).await.unwrap();
13146
13147 let new_root = [0xD7; 32];
13148 publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13149 let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated.expect("adopted");
13150 assert!(
13151 fetch_public_bundle(&bed.relay, &raid_era.url).await.unwrap_err().contains("revoked"),
13152 "the raid-era link is buried",
13153 );
13154
13155 let fresh = mint_public_link(&bed.relay, &updated, "https://new", None, None).await.unwrap();
13159 let _ = follow_rekeys(&bed.relay, &updated, &crate::db::current_session()).await.unwrap();
13160 assert!(
13161 fetch_public_bundle(&bed.relay, &fresh.url).await.is_ok(),
13162 "a link minted after the containment survives later follow passes",
13163 );
13164 }
13165
13166 #[tokio::test(flavor = "multi_thread")]
13167 async fn an_unauthorized_severing_rotation_never_touches_my_links() {
13168 let (bed, owner, me) = TestBed::new();
13172 bed.swap_to(&owner);
13173 let community = create_community(&bed.relay, "NoForge", bed.relays.clone(), None).await.unwrap();
13174 let rid = "b4".repeat(32);
13175 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::CREATE_INVITE), 1).await;
13176 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13177 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13178 let bundle_json = serde_json::to_string(&bundle).unwrap();
13179 bed.swap_to(&me);
13180 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13181 let _ = follow_control(&bed.relay, &joined).await;
13182 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13183 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13184
13185 let stranger = Keys::generate();
13187 publish_severing_base_rotation(&bed.relay, &joined, &stranger, &[me.keys.public_key(), owner.keys.public_key()], &[0xD8; 32], &joined.community_root).await;
13188
13189 let followed = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
13190 assert!(followed.updated.is_none(), "an unauthorized rotation is not adopted");
13191 assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok(), "and my link is untouched");
13192 }
13193
13194 #[tokio::test(flavor = "multi_thread")]
13195 async fn a_degraded_plane_read_is_not_evidence_of_absence() {
13196 use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
13204 let relay = AdversarialRelay::new();
13205 let (bed, owner, me) = TestBed::new();
13206 bed.swap_to(&owner);
13207 let community = create_community(relay.relay(), "Coverage", bed.relays.clone(), None).await.unwrap();
13208 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13209 let bundle_json = serde_json::to_string(&bundle).unwrap();
13210 bed.swap_to(&me);
13211 let joined = accept_parked_invite(relay.relay(), &bundle_json, None).await.unwrap();
13212 let _ = follow_control(relay.relay(), &joined).await;
13213 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13214
13215 bed.swap_to(&owner);
13216 publish_base_rotation(relay.relay(), &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &[0xE9; 32], &joined.community_root).await;
13217 bed.swap_to(&me);
13218
13219 let plane = base_rekey_group_key(&joined.community_root, joined.id(), Epoch(1));
13221 relay.on(Match::author(&plane.pk_hex()), Fault::FailFetch("relay down".into()));
13222 let _ = follow_rekeys(&relay, &joined, &crate::db::current_session()).await;
13223 let during = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13224 assert_eq!(
13225 during.root_epoch, joined.root_epoch,
13226 "an unreachable plane must not advance anything — but it must also not be \
13227 mistaken for a community that stopped rotating",
13228 );
13229
13230 relay.heal();
13232 let _ = follow_rekeys(&relay, &during, &crate::db::current_session()).await;
13233 let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13234 assert_eq!(
13235 after.root_epoch, Epoch(1),
13236 "once a relay answers, the rotation it was holding is adopted — the earlier \
13237 silence was never evidence",
13238 );
13239 }
13240
13241 #[tokio::test(flavor = "multi_thread")]
13242 async fn an_older_banlist_edition_can_never_un_ban() {
13243 let (bed, owner, _me) = TestBed::new();
13249 bed.swap_to(&owner);
13250 let community = create_community(&bed.relay, "BanMonotonic", bed.relays.clone(), None).await.unwrap();
13251 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13252 let villain_keys = Keys::generate();
13253 let villain = villain_keys.public_key().to_hex();
13254
13255 crate::db::community::set_community_banlist(&cid_hex, &[villain.clone()], 5).unwrap();
13256 assert!(crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain));
13257
13258 crate::db::community::set_community_banlist(&cid_hex, &[], 3).unwrap();
13260 assert!(
13261 crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain),
13262 "a lower-versioned edition must not un-ban",
13263 );
13264 assert!(
13265 crate::db::community::is_author_banned(&cid_hex, &villain_keys.public_key()),
13266 "and the hot cache must not hold the list we declined to persist",
13267 );
13268
13269 crate::db::community::set_community_banlist(&cid_hex, &[], 6).unwrap();
13271 assert!(
13272 !crate::db::community::is_author_banned(&cid_hex, &villain_keys.public_key()),
13273 "a newer edition still lifts a ban — monotonic, not frozen",
13274 );
13275 }
13276
13277 #[tokio::test(flavor = "multi_thread")]
13278 async fn a_withholding_relay_cannot_wipe_the_banlist_or_the_roster() {
13279 use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
13287 let relay = AdversarialRelay::new();
13288 let (bed, owner, _me) = TestBed::new();
13289 bed.swap_to(&owner);
13290 let community = create_community(relay.relay(), "WitholdWipe", bed.relays.clone(), None).await.unwrap();
13291 let rid = "c4".repeat(32);
13292 let admin = Keys::generate();
13293 publish_role(relay.relay(), &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13294 publish_grant(relay.relay(), &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
13295 let villain = Keys::generate();
13296 set_banlist(relay.relay(), &community, &[villain.public_key().to_hex()]).await.unwrap();
13297 let _ = follow_control(&relay, &community).await;
13298
13299 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13300 assert!(
13301 crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain.public_key().to_hex()),
13302 "the ban folded",
13303 );
13304 let roster_before = crate::db::community::get_community_roles(&cid_hex).unwrap();
13305 assert!(!roster_before.grants.is_empty(), "the admin's grant folded");
13306
13307 let control_pk = control::ControlPlane::of(&community).pk_hex();
13309 relay.on(Match::author(&control_pk), Fault::Empty);
13310 let _ = follow_control(&relay, &community).await;
13311
13312 assert!(
13313 crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain.public_key().to_hex()),
13314 "an empty control window must NEVER un-ban — that is the fail-open direction",
13315 );
13316 let roster_after = crate::db::community::get_community_roles(&cid_hex).unwrap();
13317 assert_eq!(
13318 roster_after.grants.len(), roster_before.grants.len(),
13319 "and must never strip standing: an empty fold is absence, not a demotion",
13320 );
13321 }
13322
13323 #[tokio::test(flavor = "multi_thread")]
13324 async fn a_banned_rotators_refounding_is_never_honored() {
13325 let (bed, owner, me) = TestBed::new();
13332 bed.swap_to(&owner);
13333 let community = create_community(&bed.relay, "BannedRotator", bed.relays.clone(), None).await.unwrap();
13334 let rid = "a9".repeat(32);
13335 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13336 let rogue = Keys::generate();
13337 publish_grant(&bed.relay, &community, &owner.keys, &rogue.public_key(), vec![rid], 1).await;
13338 let _ = follow_control(&bed.relay, &community).await;
13339
13340 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13341 let bundle_json = serde_json::to_string(&bundle).unwrap();
13342 bed.swap_to(&me);
13343 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13344 let _ = follow_control(&bed.relay, &joined).await;
13345 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13346
13347 bed.swap_to(&owner);
13349 set_banlist(&bed.relay, &community, &[rogue.public_key().to_hex()]).await.unwrap();
13350 bed.swap_to(&me);
13351 let _ = follow_control(&bed.relay, &joined).await;
13352 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13353 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
13354 assert!(
13355 crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&rogue.public_key().to_hex()),
13356 "the ban folded before the rotation arrives",
13357 );
13358
13359 bed.swap_to(&owner);
13361 publish_base_rotation(
13362 &bed.relay, &joined, &rogue,
13363 &[owner.keys.public_key(), me.keys.public_key(), rogue.public_key()],
13364 &[0xBA; 32], &joined.community_root,
13365 ).await;
13366
13367 bed.swap_to(&me);
13368 let held_before = joined.root_epoch;
13369 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
13370 assert!(!follow.self_removed, "a banned rotator can never remove anyone");
13371 let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13372 assert_eq!(
13373 after.root_epoch, held_before,
13374 "a banned member's rotation must not move the epoch — honoring it hands the community to the party just evicted",
13375 );
13376 assert_eq!(after.community_root, joined.community_root, "and must not move the root");
13377 }
13378
13379 #[tokio::test(flavor = "multi_thread")]
13380 async fn a_rekey_exclusion_keeps_my_epoch_keys() {
13381 let (bed, owner, me) = TestBed::new();
13388 bed.swap_to(&owner);
13389 let community = create_community(&bed.relay, "ExclusionKeys", bed.relays.clone(), None).await.unwrap();
13390 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13391 let bundle_json = serde_json::to_string(&bundle).unwrap();
13392 bed.swap_to(&me);
13393 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13394 let _ = follow_control(&bed.relay, &joined).await;
13395 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13396 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
13397 assert!(
13398 !crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(),
13399 "the joiner archived its epoch key",
13400 );
13401
13402 bed.swap_to(&owner);
13404 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key()], &[0xD1; 32], &joined.community_root).await;
13405
13406 bed.swap_to(&me);
13407 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
13408 assert!(follow.self_removed, "an authorized rotation that excluded me IS a removal");
13409
13410 crate::db::community::delete_community_retain_keys(&cid_hex).unwrap();
13412 assert!(
13413 crate::db::community::load_community_v2(joined.id()).unwrap().is_none(),
13414 "the community is gone",
13415 );
13416 assert!(
13417 !crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(),
13418 "but my epoch keys survive, so I can still self-delete what I wrote",
13419 );
13420 }
13421
13422 #[tokio::test(flavor = "multi_thread")]
13423 async fn a_client_that_adopted_the_losing_fork_converges_down_onto_the_canonical_one() {
13424 let (bed, owner, me) = TestBed::new();
13432 bed.swap_to(&owner);
13433 let community = create_community(&bed.relay, "ForkHeal", bed.relays.clone(), None).await.unwrap();
13434 let rid = "f1".repeat(32);
13435 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13436 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13437 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13438 let bundle_json = serde_json::to_string(&bundle).unwrap();
13439 bed.swap_to(&me);
13440 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13441 let _ = follow_control(&bed.relay, &joined).await;
13442 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13443
13444 let rival = Keys::generate();
13450 bed.swap_to(&owner);
13451 publish_grant(&bed.relay, &community, &owner.keys, &rival.public_key(), vec!["f1".repeat(32)], 1).await;
13452 let _ = follow_control(&bed.relay, &community).await;
13456 bed.swap_to(&me);
13457 let _ = follow_control(&bed.relay, &joined).await;
13458 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13459
13460 let low = [0x11u8; 32];
13461 let high = [0xF1u8; 32];
13462 let recipients = [owner.keys.public_key(), me.keys.public_key(), rival.public_key()];
13463 bed.swap_to(&owner);
13464 publish_base_rotation(&bed.relay, &joined, &owner.keys, &recipients, &high, &joined.community_root).await;
13465
13466 bed.swap_to(&me);
13468 let session = crate::db::current_session();
13469 let _ = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
13470 let forked = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13471 assert_eq!(forked.root_epoch, Epoch(1), "adopted the sibling we could see");
13472 assert_eq!(forked.community_root, high, "and it is the LOSING branch");
13473
13474 bed.swap_to(&owner);
13477 publish_base_rotation(&bed.relay, &joined, &rival, &recipients, &low, &joined.community_root).await;
13478
13479 bed.swap_to(&me);
13480 let _ = follow_rekeys(&bed.relay, &forked, &session).await.unwrap();
13481 let healed = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13482 assert_eq!(
13483 healed.community_root, low,
13484 "a settled epoch MUST converge down onto the canonical sibling, or the community stays split",
13485 );
13486 assert_eq!(healed.root_epoch, Epoch(1), "same epoch — this is a sideways step, not an advance");
13487
13488 let _ = follow_rekeys(&bed.relay, &healed, &session).await.unwrap();
13491 let settled = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13492 assert_eq!(settled.community_root, low, "convergence is down-only and stable");
13493 }
13494
13495 #[tokio::test(flavor = "multi_thread")]
13496 async fn a_deep_catch_up_never_loses_ground_and_lands_on_the_head() {
13497 use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
13507 const HOPS: u64 = 12;
13508
13509 let relay = AdversarialRelay::new();
13510 let (bed, owner, me) = TestBed::new();
13511 bed.swap_to(&owner);
13512 let community = create_community(relay.relay(), "DeepStorm", bed.relays.clone(), None).await.unwrap();
13513 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13514 let bundle_json = serde_json::to_string(&bundle).unwrap();
13515 bed.swap_to(&me);
13516 let joined = accept_parked_invite(relay.relay(), &bundle_json, None).await.unwrap();
13517 let _ = follow_control(relay.relay(), &joined).await;
13518 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13519
13520 bed.swap_to(&owner);
13523 let recipients = [owner.keys.public_key(), me.keys.public_key()];
13524 let mut cur = community.clone();
13525 let mut planes: Vec<String> = Vec::new();
13526 for i in 1..=HOPS {
13527 let root = [(0x40 + i) as u8; 32];
13528 planes.push(base_rekey_group_key(&cur.community_root, cur.id(), Epoch(i)).pk_hex());
13529 publish_base_rotation(relay.relay(), &cur, &owner.keys, &recipients, &root, &cur.community_root).await;
13530 cur.community_root = root;
13531 cur.root_epoch = Epoch(i);
13532 }
13533
13534 bed.swap_to(&me);
13540 for barrier in [5usize, 9] {
13541 relay.on(Match::author(&planes[barrier - 1]).nth(1), Fault::FailFetch("flaky link".into()));
13542 }
13543
13544 let session = crate::db::current_session();
13545 let mut last = 0u64;
13546 for pass in 0..(HOPS * 3) {
13549 let held = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13550 let now = held.root_epoch.0;
13551 assert!(
13552 now >= last,
13553 "pass {pass}: epoch went BACKWARD ({last} -> {now}); a catch-up must never lose ground",
13554 );
13555 last = now;
13556 if now == HOPS {
13557 break;
13558 }
13559 let _ = follow_rekeys(&relay, &held, &session).await;
13560 }
13561
13562 let healed = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13563 assert_eq!(
13564 healed.root_epoch, Epoch(HOPS),
13565 "a deep backlog on a failing link must still converge on the head; stuck at {}",
13566 healed.root_epoch.0,
13567 );
13568 }
13569
13570 #[tokio::test(flavor = "multi_thread")]
13571 async fn a_missing_chunk_does_not_block_adopting_the_key_i_already_hold() {
13572 let (bed, owner, me) = TestBed::new();
13584 bed.swap_to(&owner);
13585 let community = create_community(&bed.relay, "PartialChunks", bed.relays.clone(), None).await.unwrap();
13586 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13587 let bundle_json = serde_json::to_string(&bundle).unwrap();
13588 bed.swap_to(&me);
13589 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13590 let _ = follow_control(&bed.relay, &joined).await;
13591 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13592
13593 bed.swap_to(&owner);
13595 let new_root = [0xC7u8; 32];
13596 let new_epoch = Epoch(joined.root_epoch.0 + 1);
13597 let prev_commit = super::super::derive::epoch_key_commitment(joined.root_epoch, &joined.community_root);
13598 let group = base_rekey_group_key(&joined.community_root, joined.id(), new_epoch);
13599 let my_blob = rekey::build_blob_local(
13600 owner.keys.secret_key(), &owner.keys.public_key().to_bytes(),
13601 &me.keys.public_key(), RekeyScope::Root, new_epoch, &new_root,
13602 ).unwrap();
13603 let rumor = rekey::build_rekey_rumor(
13605 owner.keys.public_key(), RekeyScope::Root, new_epoch, joined.root_epoch,
13606 &prev_commit, &[my_blob], 1, 2, 2_000,
13607 my_authority_citation(&joined, &owner.keys.public_key()).as_ref(), false,
13608 ).unwrap();
13609 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
13610 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
13611
13612 bed.swap_to(&me);
13613 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
13614 assert!(!follow.self_removed, "a missing chunk is never a removal");
13615 let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13616 assert_eq!(
13617 after.root_epoch, new_epoch,
13618 "my blob was present and opened — the missing sibling chunk says nothing about MY key, \
13619 and parking here strands the whole community behind its least reachable chunk",
13620 );
13621 }
13622
13623 #[tokio::test(flavor = "multi_thread")]
13624 async fn an_empty_rotation_is_never_a_mass_removal() {
13625 let (bed, owner, me) = TestBed::new();
13634 bed.swap_to(&owner);
13635 let community = create_community(&bed.relay, "EmptyRotation", bed.relays.clone(), None).await.unwrap();
13636 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13637 let bundle_json = serde_json::to_string(&bundle).unwrap();
13638 bed.swap_to(&me);
13639 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13640 let _ = follow_control(&bed.relay, &joined).await;
13641 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13642
13643 bed.swap_to(&owner);
13645 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[], &[0xB0; 32], &joined.community_root).await;
13646
13647 bed.swap_to(&me);
13648 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
13649 assert!(
13650 !follow.self_removed,
13651 "an all-empty rotation must NEVER read as my removal — that path deletes the community",
13652 );
13653 assert!(
13654 crate::db::community::load_community_v2(joined.id()).unwrap().is_some(),
13655 "and the community must still be held",
13656 );
13657 }
13658
13659 #[tokio::test(flavor = "multi_thread")]
13660 async fn a_catch_up_interrupted_mid_walk_keeps_the_hops_it_already_took() {
13661 use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
13667 let relay = AdversarialRelay::new();
13670 let (bed, owner, me) = TestBed::new();
13671 bed.swap_to(&owner);
13672 let community = create_community(relay.relay(), "DeepCatchUp", bed.relays.clone(), None).await.unwrap();
13673
13674 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13675 let bundle_json = serde_json::to_string(&bundle).unwrap();
13676 bed.swap_to(&me);
13677 let joined = accept_parked_invite(relay.relay(), &bundle_json, None).await.unwrap();
13678 let _ = follow_control(relay.relay(), &joined).await;
13679 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13680
13681 bed.swap_to(&owner);
13683 let mut cur = community.clone();
13684 let roots = [[0xA1u8; 32], [0xA2; 32], [0xA3; 32]];
13685 let recipients = [owner.keys.public_key(), me.keys.public_key()];
13686 for (i, root) in roots.iter().enumerate() {
13687 publish_base_rotation(relay.relay(), &cur, &owner.keys, &recipients, root, &cur.community_root).await;
13688 cur.community_root = *root;
13689 cur.root_epoch = Epoch(i as u64 + 1);
13690 }
13691
13692 bed.swap_to(&me);
13694 let third_hop = base_rekey_group_key(&roots[1], joined.id(), Epoch(3));
13695 relay.on(Match::author(&third_hop.pk_hex()), Fault::FailFetch("relay dropped".into()));
13696
13697 let _ = follow_rekeys(&relay, &joined, &crate::db::current_session()).await;
13698
13699 let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13700 assert!(
13701 after.root_epoch.0 >= 2,
13702 "hops 1 and 2 succeeded and MUST be banked; an interrupted walk that reverts to epoch {} \
13703 restarts from scratch every attempt and may never converge on a flaky link",
13704 after.root_epoch.0,
13705 );
13706
13707 relay.heal();
13709 let _ = follow_rekeys(&relay, &after, &crate::db::current_session()).await;
13710 let healed = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13711 assert_eq!(healed.root_epoch, Epoch(3), "once faults cease the walk converges on the head");
13712 }
13713
13714 #[tokio::test(flavor = "multi_thread")]
13715 async fn a_promote_is_reported_as_an_authority_change() {
13716 let (bed, owner, admin) = TestBed::new();
13724 bed.swap_to(&owner);
13725 let community = create_community(&bed.relay, "PromoteReports", bed.relays.clone(), None).await.unwrap();
13726 follow_control(&bed.relay, &community).await.unwrap(); let rid = "e1".repeat(32);
13729 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13730 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
13731
13732 let control = follow_control(&bed.relay, &community).await.unwrap();
13733 assert!(
13734 control.authority_changed,
13735 "a promote MUST report an authority change — this is what re-runs the rekey walk",
13736 );
13737 assert!(control.moved(), "and therefore counts as movement");
13738 assert!(
13739 control.updated.is_none(),
13740 "while moving no document at all — which is precisely why `updated` alone was blind",
13741 );
13742 }
13743
13744 #[tokio::test(flavor = "multi_thread")]
13745 async fn a_seeded_roster_never_vetoes_a_coherent_control_fold() {
13746 let (bed, owner, admin) = TestBed::new();
13753 bed.swap_to(&owner);
13754 let community = create_community(&bed.relay, "SeedVeto", bed.relays.clone(), None).await.unwrap();
13755 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13756 let rid = "c1".repeat(32);
13757 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13758 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
13759
13760 let ghost = "d4".repeat(32);
13763 let mut seeded = crate::community::roles::CommunityRoles::default();
13764 seeded.roles.push(admin_role(&ghost, Permissions::MANAGE_CHANNELS));
13765 seeded.grants.push(crate::community::roles::MemberGrant {
13766 member: owner.keys.public_key().to_hex(),
13767 role_ids: vec![ghost.clone()],
13768 });
13769 crate::db::community::set_community_roles(&cid_hex, &seeded, 0).unwrap();
13770 crate::db::community::set_edition_head_at_epoch(&cid_hex, &ghost, 1, &[0u8; 32], &[0u8; 32], community.root_epoch.0).unwrap();
13772
13773 follow_control(&bed.relay, &community).await.unwrap().updated;
13774
13775 let folded = crate::db::community::get_community_roles(&cid_hex).unwrap();
13776 assert!(
13777 folded.grants.iter().any(|g| g.member == admin.keys.public_key().to_hex()),
13778 "a coherent fold must replace a provenance-less seed, or the client is deaf to this admin forever",
13779 );
13780 assert_ne!(
13781 crate::db::community::get_community_roles_at(&cid_hex).unwrap(),
13782 0,
13783 "and the replacement carries the plane's provenance",
13784 );
13785 }
13786
13787 #[tokio::test(flavor = "multi_thread")]
13788 async fn a_pending_severance_heals_on_a_quiet_follow_pass() {
13789 let (bed, owner, me) = TestBed::new();
13795 bed.swap_to(&owner);
13796 let community = create_community(&bed.relay, "QuietHeal", bed.relays.clone(), None).await.unwrap();
13797 let rid = "b5".repeat(32);
13798 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13799 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13800 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13801 let bundle_json = serde_json::to_string(&bundle).unwrap();
13802 bed.swap_to(&me);
13803 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13804 let _ = follow_control(&bed.relay, &joined).await;
13805 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13806 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13807
13808 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
13811 add_sever_mark(&cid_hex, 1, 5_000);
13812 assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok());
13813
13814 let followed = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
13816 assert!(followed.updated.is_none(), "nothing to adopt");
13817 assert!(
13818 fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err().contains("revoked"),
13819 "the interrupted severance healed with no rotation to carry it",
13820 );
13821 }
13822
13823 #[tokio::test(flavor = "multi_thread")]
13824 async fn a_sever_survives_a_later_ordinary_rotation_in_the_same_walk() {
13825 let (bed, owner, me) = TestBed::new();
13830 bed.swap_to(&owner);
13831 let community = create_community(&bed.relay, "StickySever", bed.relays.clone(), None).await.unwrap();
13832 let rid = "b6".repeat(32);
13833 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13834 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13835 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13836 let bundle_json = serde_json::to_string(&bundle).unwrap();
13837 bed.swap_to(&me);
13838 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13839 let _ = follow_control(&bed.relay, &joined).await;
13840 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13841 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13842
13843 let root1 = [0xE1; 32];
13845 publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &root1, &joined.community_root).await;
13846 let mut stepped = joined.clone();
13847 stepped.community_root = root1;
13848 stepped.root_epoch = Epoch(1);
13849 let root2 = [0xE2; 32];
13850 publish_base_rotation(&bed.relay, &stepped, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &root2, &root1).await;
13851
13852 let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated.expect("both hops adopt");
13853 assert_eq!(updated.root_epoch, Epoch(2), "the walk reached the ordinary head");
13854 assert!(
13855 fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err().contains("revoked"),
13856 "the containment's burial survived the ordinary hop behind it",
13857 );
13858 }
13859
13860 async fn vended_to(bed: &TestBed, before: &CommunityV2, who: &Keys) -> bool {
13863 let next = Epoch(before.root_epoch.0 + 1);
13864 let group = base_rekey_group_key(&before.community_root, before.id(), next);
13865 let chunks = fetch_rekey_chunks(&bed.relay, &before.relays, &group).await.unwrap();
13866 rekey::collect_rotations(&chunks).iter().any(|r| {
13867 rekey::find_my_blobs(
13868 &r.blobs,
13869 &r.rotator.to_bytes(),
13870 &who.public_key().to_bytes(),
13871 RekeyScope::Root,
13872 next,
13873 )
13874 .next()
13875 .is_some()
13876 })
13877 }
13878
13879 fn local_join_observed(who: &Keys, at_ms: u64, seen: u64, epoch: u64, tag: u8) -> guestbook::GuestbookEvent {
13881 guestbook::GuestbookEvent {
13882 rumor_id: [tag; 32],
13883 entry: guestbook::GuestbookEntry::Join { member: who.public_key(), invited_by: None, at_ms },
13884 epoch: Some(epoch),
13885 observed_at: Some(seen),
13886 }
13887 }
13888
13889 #[tokio::test(flavor = "multi_thread")]
13890 async fn a_backdated_join_cannot_slide_under_the_window_when_we_watched_it_arrive() {
13891 let (bed, owner, _o) = TestBed::new();
13895 bed.swap_to(&owner);
13896 let community = create_community(&bed.relay, "Backdate", bed.relays.clone(), None).await.unwrap();
13897 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13898 let liar = Keys::generate();
13899
13900 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
13902 let join = guestbook::build_join_rumor(liar.public_key(), None, 1_000);
13903 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &liar, Timestamp::from_secs(1)).unwrap();
13904 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
13905 crate::db::community::set_guestbook(&cid_hex, &[local_join_observed(&liar, 1_000, 9_000, community.root_epoch.0, 3)], 0).unwrap();
13906 crate::db::settings::set_sql_setting(format!("gb_observed_since:{cid_hex}"), "100".into()).unwrap();
13908
13909 refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
13910
13911 assert!(
13912 !vended_to(&bed, &community, &liar).await,
13913 "a backdated Join must not beat the arrival we watched with our own clock",
13914 );
13915 }
13916
13917 #[tokio::test(flavor = "multi_thread")]
13918 async fn a_client_that_started_watching_late_evicts_nobody() {
13919 let (bed, owner, _o) = TestBed::new();
13923 bed.swap_to(&owner);
13924 let community = create_community(&bed.relay, "LateWatcher", bed.relays.clone(), None).await.unwrap();
13925 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13926 let quiet = Keys::generate();
13927
13928 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
13929 let join = guestbook::build_join_rumor(quiet.public_key(), None, 1_000);
13930 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &quiet, Timestamp::from_secs(1)).unwrap();
13931 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
13932 crate::db::community::set_guestbook(&cid_hex, &[local_join_observed(&quiet, 1_000, 9_000, community.root_epoch.0, 4)], 0).unwrap();
13933 crate::db::settings::set_sql_setting(format!("gb_observed_since:{cid_hex}"), "7000".into()).unwrap();
13935
13936 refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
13937
13938 assert!(
13939 vended_to(&bed, &community, &quiet).await,
13940 "without coverage predating the window our observations prove nothing — an \
13941 old member must not be evicted for having been noticed late",
13942 );
13943 }
13944
13945 #[tokio::test(flavor = "multi_thread")]
13946 async fn a_purge_vends_only_to_the_retain_list_however_late_a_stranger_arrives() {
13947 let (bed, owner, _o) = TestBed::new();
13953 bed.swap_to(&owner);
13954 let community = create_community(&bed.relay, "Purge", bed.relays.clone(), None).await.unwrap();
13955 let kept = Keys::generate();
13956 let stranger = Keys::generate();
13957 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
13958 for (who, at_ms) in [(&kept, 2_000_000u64), (&stranger, 1_000u64)] {
13960 let join = guestbook::build_join_rumor(who.public_key(), None, at_ms);
13961 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, who, Timestamp::from_secs(at_ms / 1000)).unwrap();
13962 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
13963 }
13964
13965 refound_community_retaining(&bed.relay, &community, &[], &[kept.public_key()]).await.unwrap();
13967
13968 assert!(
13969 vended_to(&bed, &community, &kept).await,
13970 "the selected member keeps their key",
13971 );
13972 assert!(
13973 !vended_to(&bed, &community, &stranger).await,
13974 "an account not on the retain list is vended NO key, no matter when it \
13975 appeared or what Join time it claims",
13976 );
13977 }
13978
13979 #[tokio::test(flavor = "multi_thread")]
13980 async fn a_raid_window_joiner_is_vended_no_key_even_if_nobody_named_them() {
13981 let (bed, owner, _o) = TestBed::new();
13986 bed.swap_to(&owner);
13987 let community = create_community(&bed.relay, "RaceCut", bed.relays.clone(), None).await.unwrap();
13988 let established = Keys::generate();
13989 let late = Keys::generate();
13990 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
13991 for (who, at_ms) in [(&established, 2_000_000u64), (&late, 9_000_000)] {
13992 let join = guestbook::build_join_rumor(who.public_key(), None, at_ms);
13993 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, who, Timestamp::from_secs(at_ms / 1000)).unwrap();
13994 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
13995 }
13996
13997 refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
13999
14000 assert!(
14001 !vended_to(&bed, &community, &late).await,
14002 "a raid-window joiner must be vended NO key, even when nobody named them",
14003 );
14004 assert!(
14005 vended_to(&bed, &community, &established).await,
14006 "and the member who was already here keeps theirs",
14007 );
14008 }
14009
14010 #[tokio::test(flavor = "multi_thread")]
14011 async fn a_member_whose_join_is_not_on_the_plane_is_never_read_as_a_late_joiner() {
14012 let (bed, owner, _o) = TestBed::new();
14017 bed.swap_to(&owner);
14018 let community = create_community(&bed.relay, "Buried", bed.relays.clone(), None).await.unwrap();
14019 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14020 let quiet = Keys::generate();
14021 let local_join = guestbook::GuestbookEvent {
14022 rumor_id: [7u8; 32],
14023 entry: guestbook::GuestbookEntry::Join { member: quiet.public_key(), invited_by: None, at_ms: 9_900_000 },
14024 epoch: Some(community.root_epoch.0),
14025 observed_at: None,
14026 };
14027 crate::db::community::set_guestbook(&cid_hex, &[local_join], 0).unwrap();
14028
14029 refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
14031
14032 assert!(
14033 vended_to(&bed, &community, &quiet).await,
14034 "no Join on the reachable plane means OLD — absence of evidence must never evict",
14035 );
14036 }
14037
14038 #[tokio::test(flavor = "multi_thread")]
14039 async fn contain_raid_bans_cuts_and_severs_end_to_end() {
14040 let (bed, owner, _other) = TestBed::new();
14041 bed.swap_to(&owner);
14042 let community = create_community(&bed.relay, "Containment", bed.relays.clone(), None).await.unwrap();
14043 let minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
14044
14045 let established = Keys::generate();
14048 let raider = Keys::generate();
14049 let lurker = Keys::generate();
14050 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
14051 for (who, at_ms) in [(&established, 2_000_000u64), (&raider, 9_000_000), (&lurker, 9_500_000)] {
14052 let join = guestbook::build_join_rumor(who.public_key(), None, at_ms);
14053 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, who, Timestamp::from_secs(at_ms / 1000)).unwrap();
14054 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14055 }
14056
14057 let report = contain_raid(&bed.relay, community.id(), &[raider.public_key()], 5_000, &[]).await.unwrap();
14058 assert_eq!(report.banned, 1, "the convicted raider is banned");
14059 assert_eq!(report.window_cut, 1, "the lurker who joined inside the window is cut");
14060 assert!(report.refound_ok, "the severing refound landed: {:?}", report.warnings);
14061 assert_eq!((report.epoch_before, report.epoch_after), (0, 1), "the root rolled");
14062 assert_eq!(report.own_links_revoked, 1, "the door closed with it");
14063
14064 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14065 let banlist = crate::db::community::get_community_banlist(&cid_hex).unwrap();
14066 assert!(banlist.contains(&raider.public_key().to_hex()), "raider on the banlist");
14067 assert!(!banlist.contains(&lurker.public_key().to_hex()), "the lurker is cut, never banned — they can rejoin through a fresh link");
14068 assert!(!banlist.contains(&established.public_key().to_hex()));
14069 assert!(
14070 fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err().contains("revoked"),
14071 "the invite URL the raid used is a grave",
14072 );
14073
14074 let updated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14077 assert_eq!(updated.root_epoch, Epoch(1));
14078 let members = memberlist(&bed.relay, &updated).await.unwrap();
14079 assert!(members.contains(&established.public_key()), "established member survives");
14080 assert!(!members.contains(&raider.public_key()), "raider gone");
14081 }
14082
14083 #[tokio::test(flavor = "multi_thread")]
14084 async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
14085 let (bed, owner, me) = TestBed::new();
14093 bed.swap_to(&owner);
14094 let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
14095 let rid = "b1".repeat(32);
14096 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
14098 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
14099
14100 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14101 let bundle_json = serde_json::to_string(&bundle).unwrap();
14102 bed.swap_to(&me);
14103 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14104 let _ = follow_control(&bed.relay, &joined).await;
14105 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14106
14107 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
14109 let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
14110 assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
14111
14112 let new_root = [0xD4; 32];
14115 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
14116
14117 let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
14118 .expect("the owner's Refounding is adopted");
14119 assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
14120
14121 let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
14122 assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
14123 assert_eq!(
14124 crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
14125 new_root,
14126 "and the new root behind the same URL",
14127 );
14128 }
14129
14130 #[tokio::test]
14131 async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
14132 let (bed, owner, me) = TestBed::new();
14136 let admin = Keys::generate();
14137 bed.swap_to(&owner);
14138 let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
14139 let rid = "b0".repeat(32);
14140 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14141 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
14142
14143 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14144 let bundle_json = serde_json::to_string(&bundle).unwrap();
14145 bed.swap_to(&me);
14146 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14147 let _ = follow_control(&bed.relay, &joined).await;
14148 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14149
14150 publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
14152 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14153 assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
14154 }
14155
14156 #[tokio::test]
14157 async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
14158 let (bed, owner, me) = TestBed::new();
14162 let admin_a = Keys::generate();
14163 let admin_b = Keys::generate(); bed.swap_to(&owner);
14165 let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
14166 let rid = "b0".repeat(32);
14167 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14168 publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
14170 publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
14171
14172 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14173 let bundle_json = serde_json::to_string(&bundle).unwrap();
14174 bed.swap_to(&me);
14175 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14176 let _ = follow_control(&bed.relay, &joined).await;
14177 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14178
14179 publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
14181
14182 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14183 assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
14184 }
14185
14186 #[tokio::test]
14187 async fn a_retried_refounding_reuses_the_same_root() {
14188 let (_tmp, _guard, _owner) = init_test_db();
14191 let relay = MemoryRelay::new();
14192 let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
14193 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14194 let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
14195 let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
14196 assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
14197 }
14198
14199 #[tokio::test]
14200 async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
14201 let (bed, owner, attacker) = TestBed::new();
14208 bed.swap_to(&owner);
14209 let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
14210
14211 let senior = "a1".repeat(32);
14213 let mid = "a5".repeat(32);
14214 publish_role(&bed.relay, &community, &owner.keys,
14215 &Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
14216 publish_role(&bed.relay, &community, &owner.keys,
14217 &Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0 }, 1).await;
14218 publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
14219
14220 publish_role(&bed.relay, &community, &attacker.keys,
14222 &Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 2).await;
14223
14224 let authority = fetch_authority(&bed.relay, &community).await;
14225 let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
14226 assert_eq!(
14227 folded_senior.position, 1,
14228 "a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
14229 );
14230 }
14231
14232 #[tokio::test]
14233 async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
14234 let (bed, owner, admin) = TestBed::new();
14241 bed.swap_to(&owner);
14242 let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
14243 let rid = "c1".repeat(32);
14244 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
14245 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
14246
14247 let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
14249 edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
14250 let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
14251 .filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
14252 .max_by_key(|e| e.version).expect("the owner's metadata edition");
14253 assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
14254
14255 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14258 let bundle_json = serde_json::to_string(&bundle).unwrap();
14259 bed.swap_to(&admin);
14260 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14261 let _ = follow_control(&bed.relay, &joined).await;
14262 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14263 set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
14264
14265 let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
14266 .find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
14267 .expect("the admin's banlist edition");
14268 let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
14269 assert_eq!(
14270 cite.entity_id,
14271 crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
14272 "the citation must name the ACTOR'S OWN grant coordinate",
14273 );
14274 assert!(cite.version >= 1, "pinned to a real grant version");
14275 }
14276
14277 #[tokio::test]
14278 async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
14279 let (_tmp, _guard, _owner) = init_test_db();
14285 let relay = MemoryRelay::new();
14286 let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
14287
14288 let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
14289 let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
14290 edit_community_metadata(&relay, &community, &meta).await.unwrap();
14291
14292 let updated = follow_control(&relay, &community).await.unwrap().updated
14293 .expect("the metadata edition is folded");
14294 assert_eq!(
14295 updated.relays.len(),
14296 crate::community::MAX_COMMUNITY_RELAYS,
14297 "a folded relay list must be truncated, never adopted whole",
14298 );
14299
14300 let again = follow_control(&relay, &updated).await.unwrap().updated;
14304 assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
14305 }
14306
14307 #[tokio::test]
14308 async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
14309 let (bed, owner, me) = TestBed::new();
14315 bed.swap_to(&owner);
14316 let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
14317 let rid = "b2".repeat(32);
14318 publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14319 publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
14320
14321 let bundle = bundle_of(&host, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14322 let bundle_json = serde_json::to_string(&bundle).unwrap();
14323 bed.swap_to(&me);
14324 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14325 let _ = follow_control(&bed.relay, &joined).await;
14326 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14327
14328 let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
14331 mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
14332
14333 let before = bed.relay.stored_count();
14334 let new_root = [0xE1; 32];
14335 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
14336 let rotation_events = bed.relay.stored_count() - before;
14337
14338 let after_adopt = bed.relay.stored_count();
14339 follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14340 assert_eq!(
14341 bed.relay.stored_count(),
14342 after_adopt,
14343 "adopting a rotation must publish NOTHING when I minted no links here",
14344 );
14345 assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
14346 }
14347
14348 #[tokio::test]
14349 async fn an_expired_link_stops_keeping_the_community_public() {
14350 let (_tmp, _guard, _owner) = init_test_db();
14356 let relay = MemoryRelay::new();
14357 let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
14358
14359 let past = now_ms() - 60_000;
14361 mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
14362 assert!(
14363 !community_is_public(&relay, &community).await,
14364 "an already-expired link must never read as a live door",
14365 );
14366
14367 mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
14369 assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
14370 }
14371
14372 #[tokio::test]
14373 async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
14374 let (_tmp, _guard, _owner) = init_test_db();
14377 let relay = MemoryRelay::new();
14378 let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
14379 assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
14380
14381 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
14382 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
14383 let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
14384 assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
14385
14386 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
14387 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
14388 assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
14389 let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
14390 assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
14391 }
14392
14393 #[tokio::test]
14394 async fn the_registry_is_cached_locally_so_public_private_is_a_sync_read() {
14395 let (_tmp, _guard, _owner) = init_test_db();
14399 let relay = MemoryRelay::new();
14400 let community = create_community(&relay, "Cached", vec!["wss://r".into()], None).await.unwrap();
14401 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14402 let cached = || crate::db::community::get_community_invite_registry(&cid_hex).unwrap();
14403 let per_creator = || crate::db::community::get_invite_link_sets(&cid_hex).unwrap();
14406 assert!(cached().is_empty(), "a fresh community caches an empty registry");
14407 assert!(per_creator().is_empty(), "…and no per-creator sets");
14408
14409 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
14410 assert!(!cached().is_empty(), "minting caches the registry, so the UI reads Public without folding");
14411 let sets = per_creator();
14412 assert_eq!(sets.len(), 1, "the minting creator gets a set");
14413 assert_eq!(sets[0].locators.len(), 1, "carrying exactly their one live link");
14414
14415 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
14417 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
14418 assert!(cached().is_empty(), "retiring the last link empties the cache back to Private");
14419 assert!(per_creator().is_empty(), "…and clears the per-creator sets");
14420 }
14421
14422 #[tokio::test]
14423 async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
14424 let (_tmp, _guard, owner) = init_test_db();
14431 let relay = MemoryRelay::new();
14432 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
14433 mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
14434 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
14435
14436 let cid = community.id();
14437 let control = control::ControlPlane::of(&community).write_group().unwrap();
14438 let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
14439
14440 let query = Query {
14441 kinds: vec![stream::KIND_WRAP],
14442 authors: vec![control.pk_hex()],
14443 limit: Some(FOLLOW_PAGE),
14444 ..Default::default()
14445 };
14446 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
14447 let target = wraps
14448 .iter()
14449 .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
14450 .filter(|e| e.entity_id == eid)
14451 .max_by_key(|e| e.version)
14452 .expect("the owner published a registry");
14453
14454 let rogue = Keys::generate();
14457 let mut planted = false;
14458 for n in 0..4_000u64 {
14459 let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
14460 let rumor = control::build_edition_rumor(
14461 rogue.public_key(),
14462 vsk::INVITE_LINKS,
14463 &eid,
14464 target.version,
14465 target.prev_hash.as_ref(),
14466 &content,
14467 9_000,
14468 None,
14469 );
14470 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
14471 let (ed, _) = control::open_control_edition(&w, &control).unwrap();
14472 if ed.inner_id < target.inner_id {
14473 relay.publish(&w, &community.relays).await.unwrap();
14474 planted = true;
14475 break;
14476 }
14477 }
14478 assert!(planted, "the test needs a fork that wins the tiebreak");
14479
14480 assert!(
14481 community_is_public(&relay, &community).await,
14482 "an unauthorised fork must not retire the owner's live link"
14483 );
14484 }
14485
14486 #[tokio::test]
14487 async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
14488 let (_tmp, _guard, owner) = init_test_db();
14490 let relay = MemoryRelay::new();
14491 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
14492 let rogue = Keys::generate();
14493 let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
14495 let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
14496 let group = control::ControlPlane::of(&community).write_group().unwrap();
14497 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
14498 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
14499 relay.publish(&wrap, &community.relays).await.unwrap();
14500 let _ = owner;
14501 assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
14502 }
14503
14504 #[tokio::test]
14505 async fn full_lifecycle_e2e() {
14506 let (bed, owner, member) = TestBed::new();
14512
14513 bed.swap_to(&owner);
14514 let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
14515 let general = community.channels[0].id;
14516 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
14517
14518 let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
14520 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
14521
14522 let rid = "aa".repeat(32);
14524 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
14525 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
14526
14527 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14529 let bundle_json = serde_json::to_string(&bundle).unwrap();
14530 bed.swap_to(&member);
14531 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14532 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
14533 let _ = follow_control(&bed.relay, &joined).await;
14536 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14537 assert!(joined.control_root.is_some(), "the Grant's control_wrap was adopted on fold");
14538 publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
14539
14540 bed.swap_to(&owner);
14542 let updated = follow_control(&bed.relay, &community).await.unwrap().updated.expect("the admin edit folds");
14543 assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
14544
14545 set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
14547 grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
14548 let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
14549 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
14550 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
14552 assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
14553 assert!(
14555 all_texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
14556 "pre-refounding history stays reachable by paging"
14557 );
14558
14559 bed.swap_to(&member);
14562 let session = crate::db::current_session();
14563 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
14564 assert!(follow.self_removed, "the banned member is cryptographically cut");
14565
14566 bed.swap_to(&owner);
14568 dissolve_community(&bed.relay, &refounded).await.unwrap();
14569 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
14570 }
14571
14572 #[tokio::test]
14578 async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
14579 let (bed, owner, member) = TestBed::new();
14584 let attacker = Keys::generate();
14585 bed.swap_to(&owner);
14586 let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
14587 let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
14588 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
14589 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
14590 follow_control(&bed.relay, &community).await.unwrap().updated;
14592 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
14593
14594 publish_role(&bed.relay, &community, &attacker, &Role { role_id: rid.clone(), name: "pwn".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 }, 2).await;
14596
14597 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
14599 assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
14600
14601 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
14603 assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
14604 }
14605
14606 #[tokio::test]
14607 async fn memberlist_survives_a_refounding_via_the_snapshot() {
14608 let (bed, owner, member) = TestBed::new();
14611 bed.swap_to(&owner);
14612 let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
14613
14614 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
14616 bed.swap_to(&member);
14617 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
14618 bed.swap_to(&owner);
14619 assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
14620
14621 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
14623 assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
14624
14625 let members = memberlist(&bed.relay, &refounded).await.unwrap();
14627 assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
14628 assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
14629 }
14630
14631 #[tokio::test]
14632 async fn e2e_two_accounts_channels_converse_moderate() {
14633 use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
14634 use nostr_sdk::prelude::ToBech32;
14635 let (bed, a, b) = TestBed::new();
14636 let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
14637 let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
14638 println!("\n===== Concord v2 deep e2e =====");
14639 println!("[acct] A (owner) = {a_npub}");
14640 println!("[acct] B (member) = {b_npub}");
14641
14642 bed.swap_to(&a);
14644 let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
14645 let general = community.channels[0].id;
14646 println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
14647
14648 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
14652 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14653 let priv_ch = community.channel(&priv_id).unwrap();
14654 assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
14655 println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
14656
14657 let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
14659 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14660 let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
14661 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14662 println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
14663 assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
14664
14665 let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
14667 send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
14668 send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
14669 println!("[msg] A posted in #general / #announcements / #mods");
14670
14671 let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
14673 publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
14674 publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
14675 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
14676 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
14677 println!("[invite] granted B @admin · minted link {}", link.url);
14678
14679 grant_channel_access(&bed.relay, &community, &priv_id, &b.keys.public_key()).await.unwrap();
14682 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(b.keys.public_key()), Some(a.keys.public_key()), None, None)).unwrap();
14683 bed.swap_to(&b);
14684 let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14685 println!("[join] B joined; sees {} channels", b_view.channels.len());
14686 assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
14687 assert!(b_view.channels.iter().any(|c| c.id.0 == priv_id.0 && c.private && c.key.is_some()), "B holds the private channel key");
14688 assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
14689 assert!(texts_in(&bed.relay, &b_view, &priv_id).await.contains(&"A: mods-only channel".to_string()), "B reads the PRIVATE channel with the bundle key");
14690 if let Some(fresh) = follow_control(&bed.relay, &b_view).await.unwrap().updated {
14693 b_view = fresh;
14694 }
14695 println!("[follow] B folded control (roster persisted: B is @admin)");
14696
14697 send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
14699 send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
14700 println!("[msg] B replied in #general + #mods");
14701 let my_pk = b.keys.public_key();
14704 let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
14705 for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
14706 let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
14707 if let Some(o) = outcome { persist_chat(&gh, &o).await; }
14708 }
14709 assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
14710 println!("[persist] #general history persisted into the shared events store");
14711
14712 send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
14714 bed.swap_to(&a);
14715 let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
14716 send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
14717 let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
14718 send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
14719 println!("[ops] reaction + edit + delete round-tripped");
14720
14721 bed.swap_to(&b);
14723 let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
14724 println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
14725 bed.swap_to(&a);
14726 if let Some(updated) = follow_control(&bed.relay, &community).await.unwrap().updated {
14727 community = updated;
14728 }
14729 assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
14730 println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
14731
14732 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
14738 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14739 send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
14740 println!("[channel] +private #vault {} (B is unentitled — no delivery)", crate::simd::hex::bytes_to_hex_32(&vault.0));
14741 bed.swap_to(&b);
14742 let session_b2 = crate::db::current_session();
14743 if let Some(fresh) = follow_control(&bed.relay, &b_view).await.unwrap().updated {
14744 b_view = fresh;
14745 }
14746 let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
14747 assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
14748 let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
14749 if let Some(fresh) = rf.updated {
14750 b_view = fresh;
14751 }
14752 let ch = b_view.channel(&vault).expect("still recorded");
14753 assert!(ch.key.is_none(), "an unentitled member is never delivered the key");
14754 assert!(
14755 texts_in(&bed.relay, &b_view, &vault).await.is_empty(),
14756 "and reads nothing from it"
14757 );
14758 assert!(
14759 send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.is_err(),
14760 "an unentitled member cannot post into the channel either"
14761 );
14762 bed.swap_to(&a);
14763 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14764 println!("[private] #vault stayed sealed to the unentitled B (no key, no read, no send)");
14765
14766 let members = memberlist(&bed.relay, &community).await.unwrap();
14768 let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
14769 assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
14770 println!("[members] {} members: A + B present", members.len());
14771
14772 set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
14774 grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
14775 let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
14776 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
14777 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
14778 assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
14779 assert!(all_texts_in(&bed.relay, &refounded, &general).await.iter().any(|t| t == "A: welcome to the deep e2e"), "pre-ban history pages back across the new epoch");
14780 assert!(
14781 all_texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
14782 "PRIVATE history pages back across the channel's own rotation (per-channel multi-epoch archive)"
14783 );
14784 println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
14785 bed.swap_to(&b);
14787 let session_b3 = crate::db::current_session();
14788 assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
14789 println!("[ban] B's rekey-follow: self_removed = true (severed)");
14790
14791 bed.swap_to(&a);
14793 set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
14794 let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
14795 assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
14796 println!("[unban] B removed from the banlist (re-invitable)");
14797
14798 dissolve_community(&bed.relay, &refounded).await.unwrap();
14800 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
14801 println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
14802 }
14803
14804 #[tokio::test]
14810 #[ignore]
14811 async fn live_e2e_two_accounts() {
14812 use crate::community::transport::LiveTransport;
14813 use nostr_sdk::prelude::ToBech32;
14814
14815 let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
14816 let relays = vec![relay.clone()];
14817 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
14818 crate::db::close_database();
14819 crate::db::clear_id_caches();
14820 let tmp = tempfile::tempdir().unwrap();
14821 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
14822
14823 let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
14825 let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
14826
14827 let log = |line: String| {
14828 println!("{line}");
14829 if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
14830 use std::io::Write;
14831 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
14832 let _ = writeln!(f, "{line}");
14833 }
14834 }
14835 };
14836 log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
14837 log(format!("VECTOR_E2E_NSEC_A={} ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
14838 log(format!("VECTOR_E2E_NSEC_B={} ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
14839
14840 for k in [&a, &b] {
14841 let npub = k.public_key().to_bech32().unwrap();
14842 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
14843 crate::db::set_current_account(npub.clone()).unwrap();
14844 crate::db::init_database(&npub).unwrap();
14845 }
14846 let client = crate::nostr_client_builder().build();
14849 client.add_managed_relay(relay.as_str()).await.ok();
14850 client.connect().await;
14851 crate::state::set_nostr_client(client);
14852 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
14853 let become_acct = |k: &Keys| {
14854 let npub = k.public_key().to_bech32().unwrap();
14855 crate::db::set_current_account(npub.clone()).unwrap();
14856 crate::db::init_database(&npub).unwrap();
14857 crate::db::clear_id_caches();
14858 crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
14859 crate::state::set_my_public_key(k.public_key());
14860 };
14861 let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
14862
14863 become_acct(&a);
14865 let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
14866 let general = community.channels[0].id;
14867 log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
14868 send_message(&transport, &community, &general, "A: live hello").await.expect("send");
14869 let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
14870 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14871 log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
14872 grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
14873 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
14874 log(format!("[invite] B granted @admin · link {}", link.url));
14875 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(a.public_key()), None, None)).unwrap();
14876 settle().await;
14877
14878 become_acct(&b);
14880 let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
14881 log(format!("[join] B joined; {} channels", b_view.channels.len()));
14882 settle().await;
14883 let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
14884 let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
14885 log(format!("[read] B sees #general: {seen:?}"));
14886 assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
14887 send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
14888
14889 let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
14893 let hello_id = hello.event.opened().rumor_id.to_hex();
14894 let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
14895 let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
14896 let reply_rumor = chat::build_comment_rumor(bkeys.public_key(), &general, b_view.root_epoch, "B: threaded reply to hello", &hello_id, super::super::kind::MESSAGE, &a.public_key().to_hex(), None, &[], now_ms());
14897 let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
14898 transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
14899 log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
14900 settle().await;
14901
14902 become_acct(&a);
14904 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14905 let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
14906 let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
14907 if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
14908 assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
14909 assert_eq!(reply_to.as_ref().map(|r| crate::simd::hex::bytes_to_hex_32(&r.id)), Some(hello_id.clone()), "the 1111 renders inline with A's message as parent");
14910 } else {
14911 panic!("the 1111 parsed as a Message");
14912 }
14913 log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
14914 become_acct(&b);
14915 settle().await;
14916
14917 become_acct(&a);
14921 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14922 let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
14923 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14924 send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
14925 log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
14926 settle().await;
14927
14928 become_acct(&b);
14929 let session_b = crate::db::current_session();
14930 let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
14931 if let Some(fresh) = follow_control(&transport, &b_view).await.expect("B control follow").updated {
14932 b_view = fresh;
14933 }
14934 if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
14935 b_view = fresh;
14936 }
14937 let vch = b_view.channel(&vault).expect("B folded the vault");
14938 assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
14939 let vseen = texts_in(&transport, &b_view, &vault).await;
14940 log(format!("[read] B sees #vault: {vseen:?}"));
14941 assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
14942 send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
14943 settle().await;
14944
14945 become_acct(&a);
14946 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14947 assert!(
14948 texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
14949 "A reads B's private reply"
14950 );
14951 log("[private] two-way #vault conversation over the live relay".to_string());
14952
14953 set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
14955 grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
14956 let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
14957 log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
14958 settle().await;
14959 dissolve_community(&transport, &refounded).await.expect("dissolve");
14960 log("[dissolve] community sealed".to_string());
14961 log("===== LIVE e2e PASS =====".to_string());
14962 }
14963
14964 #[tokio::test]
14965 async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
14966 let (bed, owner, member) = TestBed::new();
14970 bed.swap_to(&owner);
14971 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
14972 let general = community.channels[0].id;
14973 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
14974
14975 bed.swap_to(&member);
14976 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
14977 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
14978
14979 bed.swap_to(&owner);
14981 dissolve_community(&bed.relay, &community).await.unwrap();
14982
14983 bed.swap_to(&member);
14985 let session = crate::db::current_session();
14986 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
14987 assert!(follow.dissolved, "the catch-up surfaces the tombstone");
14988 assert!(!follow.self_removed && follow.updated.is_none());
14989 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
14990 assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
14991 let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
14992 assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
14993 let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
14995 assert!(again.dissolved && again.updated.is_none());
14996 }
14997
14998 #[tokio::test]
14999 async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
15000 const PRIV_CHANNELS: usize = 6;
15005 let (bed, owner, member) = TestBed::new();
15006 bed.swap_to(&owner);
15007 let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
15008 let mut priv_ids = Vec::new();
15009 for i in 0..PRIV_CHANNELS {
15010 let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
15011 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15012 send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
15013 priv_ids.push(id);
15014 }
15015 for id in &priv_ids {
15017 grant_channel_access(&bed.relay, &community, id, &member.keys.public_key()).await.unwrap();
15018 }
15019 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
15020
15021 bed.swap_to(&member);
15023 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15024 assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
15025
15026 bed.swap_to(&owner);
15028 for epoch in 1..=2u64 {
15029 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
15030 assert_eq!(community.root_epoch, Epoch(epoch));
15031 for id in &priv_ids {
15032 send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
15033 }
15034 }
15035
15036 bed.swap_to(&member);
15038 let session = crate::db::current_session();
15039 let mut passes = 0;
15040 loop {
15041 passes += 1;
15042 assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
15043 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15044 let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
15045 assert!(!rk.self_removed);
15046 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15047 let ctl = follow_control(&bed.relay, &cur).await.unwrap().updated;
15048 if rk.updated.is_none() && ctl.is_none() {
15049 break;
15050 }
15051 }
15052 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15053 assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
15054 for id in &priv_ids {
15056 let mine = caught_up.channel(id).expect("channel survived");
15057 let theirs = community.channel(id).unwrap();
15058 assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
15059 assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
15060 let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
15061 let open = texts_in(&bed.relay, &caught_up, id).await;
15065 assert!(open.iter().any(|t| t.contains("epoch2")), "channel {id_hex} opens onto its live epoch");
15066 let paged = all_texts_in(&bed.relay, &caught_up, id).await;
15067 assert!(paged.iter().any(|t| t.contains("epoch0")), "channel {id_hex} pages back to epoch-0 history");
15068 for epoch in 1..=2u64 {
15069 assert!(paged.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} pages back to epoch-{epoch} history");
15070 }
15071 }
15072 }
15073
15074 #[tokio::test]
15075 async fn a_backlog_deeper_than_max_steps_still_converges() {
15076 let (bed, owner, member) = TestBed::new();
15081 bed.swap_to(&owner);
15082 let mut community = create_community(&bed.relay, "DeepSleeper", bed.relays.clone(), None).await.unwrap();
15083 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
15084
15085 bed.swap_to(&member);
15086 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15087 assert_eq!(joined.root_epoch, Epoch(0));
15088
15089 bed.swap_to(&owner);
15091 let depth = MAX_WALK_STEPS + 3;
15092 for _ in 0..depth {
15093 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
15094 }
15095 assert_eq!(community.root_epoch, Epoch(depth as u64));
15096
15097 bed.swap_to(&member);
15100 let mut last = 0u64;
15101 for pass in 1..=8 {
15102 let view = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15105 let session = crate::db::current_session();
15106 let _ = follow_rekeys(&bed.relay, &view, &session).await.unwrap();
15107 let view = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15108 assert!(
15109 view.root_epoch.0 >= last,
15110 "pass {pass} lost ground: {} -> {}",
15111 last, view.root_epoch.0
15112 );
15113 last = view.root_epoch.0;
15114 if last == depth as u64 {
15115 break;
15116 }
15117 }
15118 let view = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15119 assert_eq!(
15120 view.root_epoch, Epoch(depth as u64),
15121 "a backlog deeper than one walk's step bound must still converge across passes",
15122 );
15123 }
15124
15125 #[tokio::test]
15126 async fn an_offline_member_catches_up_across_three_refoundings() {
15127 use nostr_sdk::prelude::ToBech32;
15132 let (bed, owner, member) = TestBed::new();
15133 bed.swap_to(&owner);
15134 let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
15135 let general = community.channels[0].id;
15136 let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
15137 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15138 send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
15139 send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
15140 grant_channel_access(&bed.relay, &community, &mods, &member.keys.public_key()).await.unwrap();
15142 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
15143
15144 bed.swap_to(&member);
15146 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15147 assert_eq!(member_view.root_epoch, Epoch(0));
15148
15149 bed.swap_to(&owner);
15151 let stranger = Keys::generate();
15152 for epoch in 1..=3u64 {
15153 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
15154 assert_eq!(community.root_epoch, Epoch(epoch));
15155 send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
15156 send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
15157 }
15158 let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
15159 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15160 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
15161 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15162 grant_channel_access(&bed.relay, &community, &vault, &member.keys.public_key()).await.unwrap();
15165 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15166 send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
15167 set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
15168 let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
15169 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
15170
15171 bed.swap_to(&member);
15174 let session = crate::db::current_session();
15175 let mut passes = 0;
15176 loop {
15177 passes += 1;
15178 assert!(passes <= 6, "catch-up must converge, not churn");
15179 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15180 let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
15181 assert!(!rekeyed.self_removed, "the member was never removed");
15182 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15183 let controlled = follow_control(&bed.relay, &cur).await.unwrap().updated;
15184 if rekeyed.updated.is_none() && controlled.is_none() {
15185 break;
15186 }
15187 }
15188 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15189
15190 assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
15192 assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
15193 assert_eq!(caught_up.name, "Sleeper Reborn");
15194 assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
15196 let m = caught_up.channel(&mods).expect("mods survived");
15197 let owner_mods = community.channel(&mods).unwrap();
15198 assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
15199 assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
15200 let v = caught_up.channel(&vault).expect("vault folded in");
15201 assert!(v.private && v.key.is_none(), "vault folds in keyless: entitled, but never delivered");
15206 let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
15208 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
15209 assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
15210 let gen_open = texts_in(&bed.relay, &caught_up, &general).await;
15215 assert!(gen_open.contains(&"epoch3: general news".to_string()), "general opens onto its live epoch: {gen_open:?}");
15216 let gen_texts = all_texts_in(&bed.relay, &caught_up, &general).await;
15217 for epoch in 0..=3u64 {
15218 let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
15219 assert!(gen_texts.contains(&needle), "general pages back to epoch {epoch}: {gen_texts:?}");
15220 }
15221 let mods_open = texts_in(&bed.relay, &caught_up, &mods).await;
15222 assert!(mods_open.contains(&"epoch3: mods word".to_string()), "mods opens onto its live epoch: {mods_open:?}");
15223 let mods_texts = all_texts_in(&bed.relay, &caught_up, &mods).await;
15224 for epoch in 0..=3u64 {
15225 let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
15226 assert!(mods_texts.contains(&needle), "private pages back to epoch {epoch}: {mods_texts:?}");
15227 }
15228 assert!(texts_in(&bed.relay, &caught_up, &vault).await.is_empty());
15231 send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
15233 bed.swap_to(&owner);
15234 assert!(
15235 texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
15236 "the caught-up member converses at the new epoch ({})",
15237 member.keys.public_key().to_bech32().unwrap()
15238 );
15239 }
15240
15241 async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
15244 let general = community.channels[0].id;
15245 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
15246 for i in 0..n {
15247 let at = base_secs + i as u64;
15248 let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
15249 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
15250 relay.publish(&wrap, &community.relays).await.unwrap();
15251 }
15252 }
15253
15254 #[tokio::test]
15255 async fn the_history_walk_pages_past_a_multi_page_burst() {
15256 let (_tmp, _guard, owner) = init_test_db();
15259 let relay = MemoryRelay::new();
15260 let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
15261 let general = community.channels[0].id;
15262 flood_general(&relay, &community, &owner, 120, 10_000).await;
15263
15264 let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
15265 assert_eq!(all.len(), 120, "the walk pages the whole burst");
15266 let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
15268 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
15269 assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
15270 let unique: std::collections::HashSet<&String> = contents.iter().collect();
15271 assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
15272
15273 let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
15275 assert_eq!(one.len(), 50, "fetch_channel is one newest page");
15276 assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
15277 }
15278
15279 #[tokio::test]
15280 async fn a_start_until_cursor_pages_history_from_that_point_backwards() {
15281 let (_tmp, _guard, owner) = init_test_db();
15285 let relay = MemoryRelay::new();
15286 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
15287 let general = community.channels[0].id;
15288 flood_general(&relay, &community, &owner, 120, 10_000).await;
15289
15290 let older = fetch_channel_history(
15291 &relay, &community, &general, 50, 8, None, Some(10_059),
15292 crate::community::transport::Evidence::Quorum, |_| true,
15293 )
15294 .await
15295 .unwrap();
15296 let contents: Vec<String> = older.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
15297 assert_eq!(contents.len(), 60, "everything at-or-before the cursor, nothing after");
15298 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
15299 assert_eq!(contents.last().map(String::as_str), Some("msg 59"));
15300 }
15301
15302 #[tokio::test]
15303 async fn the_history_walk_stops_when_the_caller_is_caught_up() {
15304 let (_tmp, _guard, owner) = init_test_db();
15305 let relay = MemoryRelay::new();
15306 let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
15307 let general = community.channels[0].id;
15308 flood_general(&relay, &community, &owner, 120, 10_000).await;
15309
15310 let mut pages = 0usize;
15312 let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| {
15313 pages += 1;
15314 false
15315 })
15316 .await
15317 .unwrap();
15318 assert_eq!(pages, 1, "the early stop is consulted once");
15319 assert_eq!(got.len(), 50, "only the newest page is fetched");
15320 assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
15321 }
15322
15323 #[tokio::test]
15324 async fn a_same_second_history_wall_terminates_instead_of_looping() {
15325 let (_tmp, _guard, owner) = init_test_db();
15329 let relay = MemoryRelay::new();
15330 let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
15331 let general = community.channels[0].id;
15332 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
15333 for i in 0..60usize {
15334 let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
15335 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
15336 relay.publish(&wrap, &community.relays).await.unwrap();
15337 }
15338 let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
15339 assert!(got.len() >= 25, "at least the relay page is read");
15340 assert!(got.len() <= 60, "sane bound");
15341 }
15343
15344 #[tokio::test]
15345 async fn a_grant_revoke_survives_a_withholding_relay() {
15346 let (_tmp, _guard, owner) = init_test_db();
15349 let relay = MemoryRelay::new();
15350 let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
15351 let admin = Keys::generate();
15352 let rid = "d4".repeat(32);
15353 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
15354 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
15355 follow_control(&relay, &community).await.unwrap().updated; publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; follow_control(&relay, &community).await.unwrap().updated;
15358
15359 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
15361 let mut stale = community.clone();
15362 stale.relays = vec!["wss://stale".into()];
15363 let floors = load_floors(&community);
15364 let editions = fetch_control(&relay, &stale).await;
15365 let authority = fold_authority(&stale, &editions, &floors);
15366 assert!(
15367 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
15368 "the persisted grant floor refuses the rolled-back (re-granted) view"
15369 );
15370 }
15371
15372 fn load_floors(community: &CommunityV2) -> Floors {
15374 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15375 crate::db::community::get_all_edition_heads_full(&cid_hex)
15376 .unwrap_or_default()
15377 .into_iter()
15378 .filter(|(_, f)| f.0 == community.root_epoch.0)
15379 .map(|(e, f)| (e, (f.1, f.2, f.3)))
15380 .collect()
15381 }
15382
15383 async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
15385 let group = control::ControlPlane::of(&community).write_group().unwrap();
15386 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
15387 relay
15388 .fetch(&q, &community.relays)
15389 .await
15390 .unwrap_or_default()
15391 .iter()
15392 .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
15393 .collect()
15394 }
15395
15396 #[tokio::test]
15397 async fn follow_control_is_a_noop_on_a_freshly_created_community() {
15398 let (_tmp, _guard, _owner) = init_test_db();
15399 let relay = MemoryRelay::new();
15400 let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
15401 assert!(follow_control(&relay, &community).await.unwrap().updated.is_none());
15403 }
15404
15405 #[tokio::test]
15406 async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
15407 let (_tmp, _guard, owner) = init_test_db();
15408 let relay = MemoryRelay::new();
15409 let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
15410 let new_id = ChannelId([0x5a; 32]);
15411 publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
15412
15413 let updated = follow_control(&relay, &community).await.unwrap().updated.expect("a new channel changed the view");
15414 assert_eq!(updated.channels.len(), 2);
15415 let added = updated.channel(&new_id).expect("the new channel folded in");
15416 assert_eq!(added.name, "announcements");
15417 assert!(!added.private);
15418 assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
15419
15420 let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
15422 let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
15423 assert!(authors.contains(&addr), "the added channel joins the live subscription");
15424
15425 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15427 assert!(reloaded.channel(&new_id).is_some());
15428 }
15429
15430 #[tokio::test]
15431 async fn follow_control_renames_the_community_and_an_existing_channel() {
15432 let (_tmp, _guard, owner) = init_test_db();
15433 let relay = MemoryRelay::new();
15434 let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
15435 let general = community.channels[0].id;
15436 publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
15438 publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
15439
15440 let updated = follow_control(&relay, &community).await.unwrap().updated.unwrap();
15441 assert_eq!(updated.name, "New Name");
15442 assert_eq!(updated.channel(&general).unwrap().name, "lobby");
15443 assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
15444 }
15445
15446 #[tokio::test]
15447 async fn follow_control_deletes_a_channel() {
15448 let (_tmp, _guard, owner) = init_test_db();
15449 let relay = MemoryRelay::new();
15450 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
15451 let extra = ChannelId([0x77; 32]);
15452
15453 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
15455 let with_extra = follow_control(&relay, &community).await.unwrap().updated.expect("added");
15456 assert!(with_extra.channel(&extra).is_some());
15457
15458 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
15460 let updated = follow_control(&relay, &with_extra).await.unwrap().updated.expect("removed");
15461 assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
15462 assert_eq!(updated.channels.len(), 1, "only #general remains");
15463 }
15464
15465 async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
15469 let group = control::ControlPlane::of(&community).write_group().unwrap();
15470 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
15471 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
15472 for w in &wraps {
15473 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
15474 if ed.version <= max_version {
15475 relay.inject(w, &[stale_relay.to_string()]);
15476 }
15477 }
15478 }
15479 }
15480
15481 #[tokio::test]
15482 async fn a_withholding_relay_cannot_roll_back_a_rename() {
15483 let (_tmp, _guard, owner) = init_test_db();
15486 let relay = MemoryRelay::new();
15487 let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
15488 publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
15489
15490 let updated = follow_control(&relay, &community).await.unwrap().updated.expect("rename adopted");
15491 assert_eq!(updated.name, "Renamed");
15492
15493 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
15495 let mut stale_view = updated.clone();
15496 stale_view.relays = vec!["wss://stale".into()];
15497 assert!(
15498 follow_control(&relay, &stale_view).await.unwrap().updated.is_none(),
15499 "a stale-only relay must not change the held view"
15500 );
15501 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15502 assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
15503 }
15504
15505 #[tokio::test]
15506 async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
15507 let (_tmp, _guard, owner) = init_test_db();
15508 let relay = MemoryRelay::new();
15509 let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
15510 let extra = ChannelId([0x44; 32]);
15511
15512 publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
15515 assert!(follow_control(&relay, &community).await.unwrap().updated.is_none());
15516
15517 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
15518 let with_extra = follow_control(&relay, &community).await.unwrap().updated.expect("added");
15519 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
15520 let pruned = follow_control(&relay, &with_extra).await.unwrap().updated.expect("removed");
15521 assert!(pruned.channel(&extra).is_none());
15522
15523 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
15525 let mut stale_view = pruned.clone();
15526 stale_view.relays = vec!["wss://stale".into()];
15527 assert!(
15528 follow_control(&relay, &stale_view).await.unwrap().updated.is_none(),
15529 "the withheld delete must not resurrect the channel"
15530 );
15531 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15532 assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
15533 }
15534
15535 #[tokio::test]
15536 async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
15537 let (_tmp, _guard, owner) = init_test_db();
15541 let relay = MemoryRelay::new();
15542 let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
15543 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
15544 let updated = follow_control(&relay, &community).await.unwrap().updated.expect("edit adopted");
15545 assert_eq!(updated.name, "Edited");
15546
15547 let refounded = rotate_view(&updated, updated.community_root, 1);
15550 crate::db::community::save_community_v2(&refounded).unwrap();
15551 publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
15552
15553 let adopted = follow_control(&relay, &refounded).await.unwrap().updated.expect("compacted head adopted");
15554 assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
15555 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15557 let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
15558 assert!(
15559 heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
15560 "the adopted head carries the fold's epoch + version"
15561 );
15562 }
15563
15564 #[tokio::test]
15565 async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
15566 let (_tmp, _guard, owner) = init_test_db();
15571 let relay = MemoryRelay::new();
15572 let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
15573 let group = control::ControlPlane::of(&community).write_group().unwrap();
15574 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
15575
15576 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
15577 let ours = follow_control(&relay, &community).await.unwrap().updated.expect("ours adopted");
15578 assert_eq!(ours.name, "Ours");
15579
15580 let our_inner = {
15582 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
15583 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
15584 wraps
15585 .iter()
15586 .find_map(|w| {
15587 control::open_control_edition(w, &group)
15588 .ok()
15589 .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
15590 .map(|(ed, _)| ed.inner_id)
15591 })
15592 .unwrap()
15593 };
15594
15595 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
15598 let content = serde_json::to_string(&meta).unwrap();
15599 let mut ts = 2_000u64;
15600 let fork_wrap = loop {
15601 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
15602 let inner = rumor.id.unwrap().to_bytes();
15603 if inner < our_inner {
15604 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
15605 }
15606 ts += 1;
15607 };
15608 relay.publish(&fork_wrap, &community.relays).await.unwrap();
15609
15610 let converged = follow_control(&relay, &ours).await.unwrap().updated.expect("fork winner adopted");
15611 assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
15612 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15613 let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
15614 assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
15615 }
15616
15617 #[tokio::test]
15618 async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
15619 let (_tmp, _guard, owner) = init_test_db();
15623 let relay = MemoryRelay::new();
15624 let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
15625 let group = control::ControlPlane::of(&community).write_group().unwrap();
15626
15627 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
15628 let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
15629
15630 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
15632 let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
15633 let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
15634 let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
15635 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
15636 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
15637 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
15638 relay.publish(&w4, &community.relays).await.unwrap();
15639
15640 let updated = follow_control(&relay, &community).await.unwrap().updated.expect("the verified prefix applies");
15641 assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
15642
15643 relay.publish(&w3, &community.relays).await.unwrap();
15644 let healed = follow_control(&relay, &updated).await.unwrap().updated.expect("the chain heals");
15645 assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
15646 }
15647
15648 #[tokio::test]
15649 async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
15650 let (_tmp, _guard, owner) = init_test_db();
15654 let relay = MemoryRelay::new();
15655 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
15656 let group = control::ControlPlane::of(&community).write_group().unwrap();
15657
15658 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
15659 let base = follow_control(&relay, &community).await.unwrap().updated.expect("floor at v2");
15660 publish_community_meta(&relay, &base, &owner, "Three", 3).await; let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
15662
15663 let rogue = Keys::generate();
15666 for i in 0..(FOLLOW_PAGE as u64 - 1) {
15667 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
15668 let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
15669 relay.publish(&w, &community.relays).await.unwrap();
15670 }
15671 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
15674 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
15675 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
15676 relay.publish(&w4, &community.relays).await.unwrap();
15677
15678 let healed = follow_control(&relay, &base).await.unwrap().updated.expect("paging recovered the chain");
15679 assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
15680 }
15681
15682 #[tokio::test]
15683 async fn a_follow_after_delete_does_not_resurrect_the_community() {
15684 let (_tmp, _guard, owner) = init_test_db();
15687 let relay = MemoryRelay::new();
15688 let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
15689 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
15690 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15691 crate::db::community::delete_community(&cid_hex).unwrap();
15692
15693 assert!(
15694 follow_control(&relay, &community).await.unwrap().updated.is_none(),
15695 "a follow racing a delete is a no-op"
15696 );
15697 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
15698 assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
15699 }
15700
15701 #[tokio::test]
15702 async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
15703 let (_tmp, _guard, owner) = init_test_db();
15706 let relay = MemoryRelay::new();
15707 let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
15708 let new_root = [0xB2; 32];
15709 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
15710 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15711 crate::db::community::delete_community(&cid_hex).unwrap();
15712
15713 let session = crate::db::current_session();
15714 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
15715 assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
15716 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
15717 }
15718
15719 #[tokio::test]
15720 async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
15721 let (bed, owner, member) = TestBed::new();
15726 bed.swap_to(&owner);
15727 let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
15728 let group = control::ControlPlane::of(&community).write_group().unwrap();
15729 let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
15730
15731 let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
15733 let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
15734 let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
15735 let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
15736 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
15737 let r3 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&ed2.self_hash), &c3, 3_000, None);
15738 let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
15739 bed.relay.publish(&w3, &community.relays).await.unwrap();
15740
15741 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
15742 let bundle_json = serde_json::to_string(&bundle).unwrap();
15743 bed.swap_to(&member);
15744 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15745 assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
15746 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
15747 let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
15748 assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
15749 }
15750
15751 #[tokio::test]
15752 async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
15753 let (_tmp, _guard, owner) = init_test_db();
15757 let relay = MemoryRelay::new();
15758 let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
15759 let group = control::ControlPlane::of(&community).write_group().unwrap();
15760 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
15761
15762 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
15763 let ours = follow_control(&relay, &community).await.unwrap().updated.expect("ours adopted");
15764 assert_eq!(ours.name, "Ours");
15765 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15766 let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
15767 let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
15768
15769 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
15772 let content = serde_json::to_string(&meta).unwrap();
15773 let mut ts = 5_000u64;
15774 let fork_wrap = loop {
15775 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
15776 if rumor.id.unwrap().to_bytes() > our_inner {
15777 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
15778 }
15779 ts += 1;
15780 };
15781 inject_stale_prefix(&relay, &community, 1, "wss://stale").await; relay.inject(&fork_wrap, &["wss://stale".to_string()]);
15783 let mut stale_view = ours.clone();
15784 stale_view.relays = vec!["wss://stale".into()];
15785
15786 assert!(
15787 follow_control(&relay, &stale_view).await.unwrap().updated.is_none(),
15788 "a losing fork served without our floor edition changes nothing"
15789 );
15790 let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
15791 assert_eq!(held_after, held_before, "the floor row is untouched");
15792 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15793 assert_eq!(held.name, "Ours", "the held state is untouched");
15794 }
15795
15796 #[tokio::test]
15797 async fn follow_control_ignores_a_non_owner_edition() {
15798 let (_tmp, _guard, _owner) = init_test_db();
15802 let relay = MemoryRelay::new();
15803 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
15804 let rogue = Keys::generate();
15805 let rogue_id = ChannelId([0x99; 32]);
15806 publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
15807
15808 assert!(
15809 follow_control(&relay, &community).await.unwrap().updated.is_none(),
15810 "a non-owner control edition is not folded"
15811 );
15812 }
15813
15814 #[tokio::test]
15815 async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
15816 let (_tmp, _guard, owner) = init_test_db();
15820 let relay = MemoryRelay::new();
15821 let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
15822 let priv_id = ChannelId([0x33; 32]);
15823 publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
15824
15825 let updated = follow_control(&relay, &community)
15826 .await
15827 .unwrap()
15828 .updated
15829 .expect("the keyless record is a change");
15830 let ch = updated.channel(&priv_id).expect("the private channel is recorded");
15831 assert!(ch.private && ch.key.is_none(), "recorded keyless");
15832 assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
15833 assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
15834 assert!(
15835 fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
15836 "a keyless fetch returns empty (and never queries the root plane)"
15837 );
15838 assert!(
15839 send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
15840 "a keyless send refuses"
15841 );
15842 let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
15845 let rch = reloaded.channel(&priv_id).unwrap();
15846 assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
15847 let bundle = bundle_of(&reloaded, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
15851 assert!(
15852 !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
15853 "an ungrantable keyless channel stays out of invite bundles"
15854 );
15855 }
15856
15857 #[tokio::test]
15858 async fn a_link_bundle_never_carries_a_private_channel_key() {
15859 let (_tmp, _guard, _owner) = init_test_db();
15864 let relay = MemoryRelay::new();
15865 let community = create_community(&relay, "Leak", vec!["wss://r".into()], None).await.unwrap();
15866 create_private_channel(&relay, &community, "mods").await.unwrap();
15867 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15868 let priv_hex = held
15869 .channels
15870 .iter()
15871 .find(|c| c.private)
15872 .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
15873 .expect("the private channel is held WITH its key");
15874
15875 let link = bundle_of(&held, BundleAudience::Link, None, None, None);
15876 assert!(
15877 !link.channels.iter().any(|c| c.id == priv_hex),
15878 "a held private key must never ride a link bundle"
15879 );
15880 assert!(
15881 link.channels.iter().any(|c| c.id != priv_hex),
15882 "the public channels still ride it"
15883 );
15884
15885 let stranger = bundle_of(&held, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
15889 assert!(
15890 !stranger.channels.iter().any(|c| c.id == priv_hex),
15891 "an unentitled member gets no private key"
15892 );
15893 let mine = bundle_of(&held, BundleAudience::Member(me_pk().unwrap()), None, None, None);
15894 assert!(
15895 mine.channels.iter().any(|c| c.id == priv_hex),
15896 "the creator is entitled via the companion access role"
15897 );
15898 }
15899
15900 async fn seat_ranked_role(
15904 relay: &MemoryRelay,
15905 community: &CommunityV2,
15906 member: &PublicKey,
15907 name: &str,
15908 position: u32,
15909 permissions: crate::community::roles::Permissions,
15910 ) -> String {
15911 let role_id = crate::crypto::sha256_hex(format!("test/role/{name}/{position}").as_bytes());
15912 let role = crate::community::roles::Role {
15913 role_id: role_id.clone(),
15914 name: name.to_string(),
15915 position,
15916 permissions,
15917 scope: crate::community::roles::RoleScope::Server,
15918 color: 0,
15919 };
15920 set_role(relay, community, &role).await.unwrap();
15921 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15922 let mut ids: Vec<String> = {
15923 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15924 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
15925 roster.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect()
15926 };
15927 if !ids.contains(&role_id) {
15928 ids.push(role_id.clone());
15929 }
15930 grant_roles(relay, &held, member, ids).await.unwrap();
15931 role_id
15932 }
15933
15934 fn act_as(who: &Keys) {
15937 crate::state::MY_SECRET_KEY.store_from_keys(who, &[]);
15938 crate::state::set_my_public_key(who.public_key());
15939 }
15940
15941 #[tokio::test]
15942 async fn a_moderator_cannot_revoke_channel_access_from_a_superior_and_the_channel_never_rotates() {
15943 let (_tmp, _guard, owner) = init_test_db();
15949 let relay = MemoryRelay::new();
15950 let community = create_community(&relay, "Ranked", vec!["wss://r".into()], None).await.unwrap();
15951 let vault = create_private_channel(&relay, &community, "vault").await.unwrap();
15952
15953 let superior = Keys::generate();
15954 let moderator = Keys::generate();
15955 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15956 grant_admin(&relay, &held, &superior.public_key()).await.unwrap();
15957 seat_ranked_role(
15958 &relay,
15959 &community,
15960 &moderator.public_key(),
15961 "Moderator",
15962 5,
15963 crate::community::roles::Permissions(crate::community::roles::Permissions::MANAGE_ROLES),
15964 )
15965 .await;
15966
15967 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15969 grant_channel_access(&relay, &held, &vault, &superior.public_key()).await.unwrap();
15970 let before = crate::db::community::load_community_v2(community.id())
15971 .unwrap()
15972 .unwrap()
15973 .channel(&vault)
15974 .unwrap()
15975 .epoch;
15976
15977 act_as(&moderator);
15978 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15979 let err = revoke_channel_access(&relay, &held, &vault, &superior.public_key())
15980 .await
15981 .expect_err("a moderator must not act on an admin");
15982 assert!(err.contains("outrank"), "refused for RANK, not some incidental reason: {err}");
15983
15984 let after = crate::db::community::load_community_v2(community.id())
15985 .unwrap()
15986 .unwrap()
15987 .channel(&vault)
15988 .unwrap()
15989 .epoch;
15990 assert_eq!(after, before, "the refusal must also stop the rotation — the rekey IS the severance");
15991 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15992 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
15993 let chan_hex = crate::simd::hex::bytes_to_hex_32(&vault.0);
15994 let owner_hex = owner.public_key().to_hex();
15995 assert!(
15996 roster.is_entitled(Some(&owner_hex), &superior.public_key().to_hex(), &chan_hex, &[], &[]),
15997 "and the superior keeps their access role"
15998 );
15999 }
16000
16001 #[tokio::test]
16002 async fn a_moderator_cannot_grant_channel_access_to_a_superior_but_the_owner_stays_grantable() {
16003 let (_tmp, _guard, owner) = init_test_db();
16007 let relay = MemoryRelay::new();
16008 let community = create_community(&relay, "Ranked", vec!["wss://r".into()], None).await.unwrap();
16009 let vault = create_private_channel(&relay, &community, "vault").await.unwrap();
16010
16011 let superior = Keys::generate();
16012 let moderator = Keys::generate();
16013 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16014 grant_admin(&relay, &held, &superior.public_key()).await.unwrap();
16015 let chan_hex = crate::simd::hex::bytes_to_hex_32(&vault.0);
16016 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16019 grant_channel_access(&relay, &held, &vault, &moderator.public_key()).await.unwrap();
16020 seat_ranked_role(
16021 &relay,
16022 &community,
16023 &moderator.public_key(),
16024 "Moderator",
16025 5,
16026 crate::community::roles::Permissions(crate::community::roles::Permissions::MANAGE_ROLES),
16027 )
16028 .await;
16029
16030 act_as(&moderator);
16031 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16032 let err = grant_channel_access(&relay, &held, &vault, &superior.public_key())
16033 .await
16034 .expect_err("a moderator must not grant against an admin");
16035 assert!(err.contains("outrank"), "refused for RANK: {err}");
16036
16037 grant_channel_access(&relay, &held, &vault, &owner.public_key())
16039 .await
16040 .expect("the owner is not a rank target, and may still be handed a key");
16041 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16042 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16043 let owner_hex = owner.public_key().to_hex();
16044 assert!(
16045 roster.is_entitled(Some(&owner_hex), &owner_hex, &chan_hex, &[], &[]),
16046 "the owner's access role landed"
16047 );
16048 }
16049
16050 #[tokio::test]
16051 async fn a_private_channel_mints_its_access_role_and_entitlement_follows_the_grant() {
16052 let (_tmp, _guard, _owner) = init_test_db();
16055 let relay = MemoryRelay::new();
16056 let community = create_community(&relay, "Scoped", vec!["wss://r".into()], None).await.unwrap();
16057 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
16058 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16059 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16060
16061 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16062 let access = roster.channel_roles(&chan_hex);
16063 assert_eq!(access.len(), 1, "the channel minted exactly one access role");
16064 assert!(
16065 access[0].permissions == crate::community::roles::Permissions::empty(),
16066 "the access role confers READ access (key possession), never authority"
16067 );
16068 assert_eq!(access[0].name, "mods", "named for its channel");
16069
16070 let stranger = Keys::generate().public_key();
16072 let owner_hex = community.owner().unwrap().to_hex();
16073 assert!(!roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]));
16074
16075 let role_id = access[0].role_id.clone();
16078 assert!(
16079 roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, std::slice::from_ref(&role_id), &[]),
16080 "the grant overlay entitles before the fold catches up"
16081 );
16082
16083 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16084 grant_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
16085 let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
16086 assert!(
16087 after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
16088 "the grant landed in the local roster (the fold runs later)"
16089 );
16090 let vend = bundle_of(&held, BundleAudience::Member(stranger), None, None, None);
16091 assert!(
16092 vend.channels.iter().any(|c| c.id == chan_hex),
16093 "a now-entitled member's bundle carries the channel key"
16094 );
16095
16096 revoke_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
16097 let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
16098 assert!(
16099 !after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
16100 "the revoke dropped the access role"
16101 );
16102 let rotated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16103 assert_eq!(
16104 rotated.channel(&priv_id).unwrap().epoch,
16105 Epoch(2),
16106 "the revoke rotated the channel — a removal that doesn't rekey severs nobody"
16107 );
16108
16109 let access = crate::VectorCore.channel_access(&cid_hex, &chan_hex).unwrap();
16111 assert_eq!(access["private"], true);
16112 assert_eq!(access["readable"], true, "we minted it, so we hold its key");
16113 assert_eq!(access["roles"].as_array().unwrap().len(), 1, "one access role");
16114 let holders = access["members"].as_array().unwrap();
16115 let me_npub = {
16116 use nostr_sdk::prelude::ToBech32;
16117 me_pk().unwrap().to_bech32().unwrap()
16118 };
16119 assert_eq!(holders.len(), 1, "only the creator holds it — the revoked member is gone");
16120 assert_eq!(holders[0], serde_json::json!(me_npub), "and that holder is the creator");
16121 }
16122
16123 #[tokio::test]
16124 async fn a_public_ban_severs_the_private_channels_the_member_could_read() {
16125 let (_tmp, _guard, _owner) = init_test_db();
16130 let relay = MemoryRelay::new();
16131 let community = create_community(&relay, "Sever", vec!["wss://r".into()], None).await.unwrap();
16132 let mods = create_private_channel(&relay, &community, "mods").await.unwrap();
16133 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16134 let vault = create_private_channel(&relay, &held, "vault").await.unwrap();
16135 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16136
16137 let spammer = Keys::generate().public_key();
16138 let bystander = Keys::generate().public_key();
16139 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16140 grant_channel_access(&relay, &held, &mods, &spammer).await.unwrap();
16141 grant_channel_access(&relay, &held, &mods, &bystander).await.unwrap();
16142
16143 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16146 let stripped: Vec<String> = roster.roles_of(&spammer.to_hex()).map(|r| r.role_id.clone()).collect();
16147 assert!(!stripped.is_empty(), "the grant landed before the strip");
16148 grant_roles(&relay, &held, &spammer, vec![]).await.unwrap();
16149
16150 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16151 let rotated = sever_banned_private_reads(&relay, &held, &[(spammer, stripped)]).await.unwrap();
16152 assert_eq!(rotated, 1, "exactly the one channel they could read rotated");
16153 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16154 assert_eq!(after.channel(&mods).unwrap().epoch, Epoch(2), "the reachable channel advanced");
16155 assert_eq!(after.channel(&vault).unwrap().epoch, Epoch(1), "the unreachable channel did not");
16156
16157 let stranger = Keys::generate().public_key();
16159 let n = sever_banned_private_reads(&relay, &after, &[(stranger, Vec::new())]).await.unwrap();
16160 assert_eq!(n, 0, "no entitlement, no rotation");
16161 }
16162
16163 #[tokio::test]
16164 async fn ban_severance_refuses_without_manage_channels() {
16165 let (bed, owner, moderator) = TestBed::new();
16169 bed.swap_to(&owner);
16170 let community = create_community(&bed.relay, "Gated", bed.relays.clone(), None).await.unwrap();
16171 create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16172 let rid = "c1".repeat(32);
16173 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
16174 publish_grant(&bed.relay, &community, &owner.keys, &moderator.keys.public_key(), vec![rid], 1).await;
16175 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16176 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
16177 let bundle_json = serde_json::to_string(&bundle).unwrap();
16178
16179 bed.swap_to(&moderator);
16180 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
16181 let _ = follow_control(&bed.relay, &joined).await;
16182 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16183 let target = Keys::generate().public_key();
16184 let err = sever_banned_private_reads(&bed.relay, &joined, &[(target, Vec::new())]).await.unwrap_err();
16185 assert!(err.contains("permission"), "refused at the gate, not mid-rotation: {err}");
16186 }
16187
16188 #[tokio::test]
16189 async fn set_banlist_refuses_an_author_the_fold_would_reject() {
16190 let (bed, owner, member) = TestBed::new();
16194 bed.swap_to(&owner);
16195 let community = create_community(&bed.relay, "Soap", bed.relays.clone(), None).await.unwrap();
16196 let rid = "c2".repeat(32);
16197 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
16198 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16199 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
16200 let bundle_json = serde_json::to_string(&bundle).unwrap();
16201
16202 bed.swap_to(&member);
16204 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
16205 let _ = follow_control(&bed.relay, &joined).await;
16206 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16207 let target = Keys::generate().public_key().to_hex();
16208 let err = set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap_err();
16209 assert!(err.contains("BAN"), "refused for the missing bit: {err}");
16210 assert!(
16211 crate::db::community::get_community_banlist(&crate::simd::hex::bytes_to_hex_32(&joined.id().0)).unwrap().is_empty(),
16212 "no phantom echo cached"
16213 );
16214
16215 bed.swap_to(&owner);
16218 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
16219 let peer = Keys::generate().public_key();
16220 publish_grant(&bed.relay, &community, &owner.keys, &peer, vec![rid], 2).await;
16221 bed.swap_to(&member);
16222 let _ = follow_control(&bed.relay, &joined).await;
16223 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16224 let err = set_banlist(&bed.relay, &joined, &[peer.to_hex()]).await.unwrap_err();
16225 assert!(err.contains("outrank"), "an equal is not actionable: {err}");
16226 set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap();
16227 }
16228
16229 #[tokio::test]
16230 async fn metadata_and_link_minting_refuse_unauthorized_authors() {
16231 let (bed, owner, member) = TestBed::new();
16234 bed.swap_to(&owner);
16235 let community = create_community(&bed.relay, "Locked", bed.relays.clone(), None).await.unwrap();
16236 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16237 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
16238 let bundle_json = serde_json::to_string(&bundle).unwrap();
16239
16240 bed.swap_to(&member);
16241 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
16242 let _ = follow_control(&bed.relay, &joined).await;
16243 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16244 let meta = control::CommunityMetadata {
16245 name: "Hijacked".into(),
16246 description: None,
16247 relays: vec![],
16248 icon: None,
16249 banner: None,
16250 custom: None,
16251 extra: Default::default(),
16252 };
16253 assert!(edit_community_metadata(&bed.relay, &joined, &meta).await.is_err(), "metadata edit refused");
16254 assert!(
16255 mint_public_link(&bed.relay, &joined, "https://vectorapp.io/i/", None, None).await.is_err(),
16256 "link minting refused"
16257 );
16258 }
16259
16260 #[tokio::test]
16261 async fn a_vended_key_parks_until_the_fold_proves_the_grant_then_adopts() {
16262 let (bed, owner, member) = TestBed::new();
16266 bed.swap_to(&owner);
16267 let community = create_community(&bed.relay, "Vend", bed.relays.clone(), None).await.unwrap();
16268 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16269 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16270 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16271 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16272 let real_key = held.channel(&priv_id).unwrap().key.unwrap();
16273 let owner_hex = community.owner().unwrap().to_hex();
16274
16275 bed.swap_to(&member);
16278 let me = member.keys.public_key().to_hex();
16279 let mut member_view = held.clone();
16282 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16283 c.key = None;
16284 c.epoch = Epoch(0);
16285 }
16286
16287 let empty = crate::community::roles::CommunityRoles::default();
16290 assert!(matches!(
16291 judge_channel_key_vend(&member_view, &empty, &priv_id, Epoch(1), &owner_hex),
16292 VendVerdict::Park(_)
16293 ));
16294
16295 let mut public_view = member_view.clone();
16297 if let Some(c) = public_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16298 c.private = false;
16299 }
16300 assert!(matches!(
16301 judge_channel_key_vend(&public_view, &empty, &priv_id, Epoch(1), &owner_hex),
16302 VendVerdict::Refuse(_)
16303 ));
16304
16305 assert!(matches!(
16307 judge_channel_key_vend(&member_view, &empty, &ChannelId([0x77; 32]), Epoch(1), &owner_hex),
16308 VendVerdict::Park(_)
16309 ));
16310
16311 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
16314 crate::db::community::set_community_roles(&cid_hex, &empty, 0).unwrap();
16315 crate::db::community::save_community_v2(&member_view).unwrap();
16316 let session = crate::db::current_session();
16317 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16318 assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "unprovable vend adopts nothing");
16319 assert_eq!(
16320 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
16321 1,
16322 "and stays parked for the next fold"
16323 );
16324
16325 let access = crate::community::roles::Role {
16327 role_id: "44".repeat(32),
16328 name: "mods".into(),
16329 position: u32::MAX - 1,
16330 permissions: crate::community::roles::Permissions::empty(),
16331 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
16332 color: 0,
16333 };
16334 let folded = crate::community::roles::CommunityRoles {
16335 grants: vec![crate::community::roles::MemberGrant { member: me.clone(), role_ids: vec![access.role_id.clone()] }],
16336 roles: vec![access],
16337 };
16338 crate::db::community::set_community_roles(&cid_hex, &folded, 1).unwrap();
16339 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16340 let adopted = absorb_parked_channel_keys(&reloaded, &session);
16341 assert_eq!(adopted.len(), 1, "the re-judge adopts once the grant folds");
16342
16343 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16344 let ch = after.channel(&priv_id).unwrap();
16345 assert_eq!(ch.key, Some(real_key), "adopted the vended key");
16346 assert_eq!(ch.epoch, Epoch(1));
16347 assert!(
16348 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
16349 "and the park is discharged"
16350 );
16351 }
16352
16353 #[tokio::test]
16354 async fn a_vend_from_an_unentitled_sender_is_never_seated_but_survives_for_the_real_one() {
16355 let (bed, owner, member) = TestBed::new();
16361 bed.swap_to(&owner);
16362 let community = create_community(&bed.relay, "Vendors", bed.relays.clone(), None).await.unwrap();
16363 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16364 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16365 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16366 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16367 let owner_hex = community.owner().unwrap().to_hex();
16368
16369 bed.swap_to(&member);
16370 let mut member_view = held.clone();
16371 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16372 c.key = None;
16373 c.epoch = Epoch(0);
16374 }
16375 crate::db::community::save_community_v2(&member_view).unwrap();
16376
16377 let access = crate::community::roles::Role {
16380 role_id: "66".repeat(32),
16381 name: "mods".into(),
16382 position: u32::MAX - 1,
16383 permissions: crate::community::roles::Permissions::empty(),
16384 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
16385 color: 0,
16386 };
16387 let roster = crate::community::roles::CommunityRoles {
16388 grants: vec![crate::community::roles::MemberGrant {
16389 member: member.keys.public_key().to_hex(),
16390 role_ids: vec![access.role_id.clone()],
16391 }],
16392 roles: vec![access],
16393 };
16394 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
16395
16396 let stranger_hex = Keys::generate().public_key().to_hex();
16397 let junk = [0x11; 32];
16398 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &junk, &stranger_hex).unwrap();
16399
16400 let session = crate::db::current_session();
16401 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16402 assert!(
16403 absorb_parked_channel_keys(&reloaded, &session).is_empty(),
16404 "an unentitled vendor seats nothing"
16405 );
16406 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16407 assert_eq!(after.channel(&priv_id).unwrap().key, None, "the channel is still keyless");
16408 assert_eq!(
16409 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
16410 1,
16411 "parked, not refused — the vendor's own entitlement may simply not have folded here yet"
16412 );
16413
16414 let real = [0x5a; 32];
16416 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &real, &owner_hex).unwrap();
16417 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16418 assert_eq!(
16419 absorb_parked_channel_keys(&reloaded, &session).len(),
16420 1,
16421 "the genuine vend is seated — a stranger cannot occupy the slot"
16422 );
16423 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16424 assert_eq!(after.channel(&priv_id).unwrap().key, Some(real), "and it is the ENTITLED vendor's key");
16425 assert!(
16426 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
16427 "both candidates discharge once one is seated"
16428 );
16429 }
16430
16431 #[tokio::test]
16432 async fn a_vend_at_epoch_zero_is_adopted_onto_a_keyless_channel() {
16433 let (bed, owner, member) = TestBed::new();
16438 bed.swap_to(&owner);
16439 let community = create_community(&bed.relay, "EpochZero", bed.relays.clone(), None).await.unwrap();
16440 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16441 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16442 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16443 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16444 let vended = [0x5a; 32];
16445 let owner_hex = community.owner().unwrap().to_hex();
16446
16447 bed.swap_to(&member);
16448 let mut member_view = held.clone();
16450 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16451 c.key = None;
16452 c.epoch = Epoch(0);
16453 }
16454 crate::db::community::save_community_v2(&member_view).unwrap();
16455
16456 let access = crate::community::roles::Role {
16458 role_id: "77".repeat(32),
16459 name: "mods".into(),
16460 position: u32::MAX - 1,
16461 permissions: crate::community::roles::Permissions::empty(),
16462 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
16463 color: 0,
16464 };
16465 let roster = crate::community::roles::CommunityRoles {
16466 grants: vec![crate::community::roles::MemberGrant {
16467 member: member.keys.public_key().to_hex(),
16468 role_ids: vec![access.role_id.clone()],
16469 }],
16470 roles: vec![access],
16471 };
16472 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
16473 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &vended, &owner_hex).unwrap();
16474
16475 let session = crate::db::current_session();
16476 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16477 let adopted = absorb_parked_channel_keys(&reloaded, &session);
16478 assert_eq!(adopted.len(), 1, "an epoch-0 vend onto a keyless channel is adopted");
16479
16480 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16481 let ch = after.channel(&priv_id).unwrap();
16482 assert_eq!(ch.key, Some(vended), "the key actually landed on the row");
16483 assert_eq!(ch.epoch, Epoch(0), "at the epoch the vendor named");
16484 assert!(
16485 !after.channel_read_coords(ch).is_empty(),
16486 "and the channel is readable — the whole point"
16487 );
16488 assert!(
16489 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
16490 "the park is discharged"
16491 );
16492 }
16493
16494 #[tokio::test]
16495 async fn a_wildly_ahead_vend_epoch_is_refused_not_seated() {
16496 let (bed, owner, member) = TestBed::new();
16502 bed.swap_to(&owner);
16503 let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
16504 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16505 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16506 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16507 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16508 let owner_hex = community.owner().unwrap().to_hex();
16509
16510 bed.swap_to(&member);
16511 let mut member_view = held.clone();
16512 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16513 c.key = None;
16514 c.epoch = Epoch(0);
16515 }
16516 crate::db::community::save_community_v2(&member_view).unwrap();
16517 let access = crate::community::roles::Role {
16518 role_id: "99".repeat(32),
16519 name: "mods".into(),
16520 position: u32::MAX - 1,
16521 permissions: crate::community::roles::Permissions::empty(),
16522 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
16523 color: 0,
16524 };
16525 let roster = crate::community::roles::CommunityRoles {
16526 grants: vec![crate::community::roles::MemberGrant {
16527 member: member.keys.public_key().to_hex(),
16528 role_ids: vec![access.role_id.clone()],
16529 }],
16530 roles: vec![access],
16531 };
16532 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
16533 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16534
16535 assert!(matches!(
16537 judge_channel_key_vend(&reloaded, &roster, &priv_id, Epoch(1 << 40), &owner_hex),
16538 VendVerdict::Refuse(_)
16539 ));
16540 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1 << 40, &[0xEE; 32], &owner_hex).unwrap();
16542 let session = crate::db::current_session();
16543 assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "a poison epoch adopts nothing");
16544 assert!(
16545 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
16546 "and the row is discharged rather than parked forever"
16547 );
16548 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16550 assert_eq!(after.channel(&priv_id).unwrap().epoch, Epoch(0), "head never advanced");
16551 assert!(matches!(
16552 judge_channel_key_vend(&after, &roster, &priv_id, Epoch(1), &owner_hex),
16553 VendVerdict::Accept
16554 ));
16555 }
16556
16557 #[tokio::test]
16558 async fn a_channel_rename_lands_locally_without_waiting_for_the_fold() {
16559 let (_tmp, _guard, _owner) = init_test_db();
16562 let relay = MemoryRelay::new();
16563 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
16564 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
16565 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16566 let key_before = held.channel(&priv_id).unwrap().key;
16567
16568 let mut meta = held.channel(&priv_id).unwrap().metadata();
16569 meta.name = "staff".into();
16570 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
16571
16572 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16573 let ch = after.channel(&priv_id).unwrap();
16574 assert_eq!(ch.name, "staff", "the rename is visible immediately");
16575 assert!(ch.private, "and privacy survives the edit");
16576 assert_eq!(ch.key, key_before, "as does the key — a rename is not a rotation");
16577 }
16578
16579 #[tokio::test]
16580 async fn a_channel_rename_carries_its_companion_access_role() {
16581 let (_tmp, _guard, _owner) = init_test_db();
16582 let relay = MemoryRelay::new();
16583 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
16584 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
16585 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16586 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16587
16588 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16589 let before = roster.channel_roles(&chan_hex);
16590 assert_eq!(before.len(), 1, "one companion role, minted at create");
16591 assert_eq!(before[0].name, "mods", "named after the channel it gates");
16592 let role_id = before[0].role_id.clone();
16593
16594 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16595 let mut meta = held.channel(&priv_id).unwrap().metadata();
16596 meta.name = "staff".into();
16597 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
16598
16599 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16600 let after = roster.channel_roles(&chan_hex);
16601 assert_eq!(after.len(), 1, "renamed in place, never duplicated");
16602 assert_eq!(after[0].role_id, role_id, "a rename is a versioned edit of the same id");
16603 assert_eq!(after[0].name, "staff", "the access role followed the channel");
16604 assert_eq!(
16605 after[0].permissions,
16606 crate::community::roles::Permissions::empty(),
16607 "and still confers read access, never authority"
16608 );
16609 }
16610
16611 #[tokio::test]
16612 async fn a_customised_access_role_name_survives_a_channel_rename() {
16613 let (_tmp, _guard, _owner) = init_test_db();
16616 let relay = MemoryRelay::new();
16617 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
16618 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
16619 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16620 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16621
16622 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16623 let mut role = roster.channel_roles(&chan_hex)[0].clone();
16624 role.name = "Lab Insiders".into();
16625 set_role(&relay, &community, &role).await.unwrap();
16626 merge_local_roster(&cid_hex, Some(&role), None);
16627
16628 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16629 let mut meta = held.channel(&priv_id).unwrap().metadata();
16630 meta.name = "staff".into();
16631 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
16632
16633 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16634 assert_eq!(
16635 roster.channel_roles(&chan_hex)[0].name,
16636 "Lab Insiders",
16637 "a deliberate name is left alone"
16638 );
16639 }
16640
16641 #[tokio::test]
16642 async fn a_squatted_park_row_cannot_suppress_the_genuine_vend() {
16643 let (bed, owner, member) = TestBed::new();
16649 bed.swap_to(&owner);
16650 let community = create_community(&bed.relay, "Squat", bed.relays.clone(), None).await.unwrap();
16651 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16652 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16653 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16654 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16655 let real_key = held.channel(&priv_id).unwrap().key.unwrap();
16656 let owner_hex = community.owner().unwrap().to_hex();
16657
16658 bed.swap_to(&member);
16659 let mut member_view = held.clone();
16660 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16661 c.key = None;
16662 c.epoch = Epoch(0);
16663 }
16664 crate::db::community::save_community_v2(&member_view).unwrap();
16665 let access = crate::community::roles::Role {
16666 role_id: "aa".repeat(32),
16667 name: "mods".into(),
16668 position: u32::MAX - 1,
16669 permissions: crate::community::roles::Permissions::empty(),
16670 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
16671 color: 0,
16672 };
16673 let roster = crate::community::roles::CommunityRoles {
16674 grants: vec![crate::community::roles::MemberGrant {
16675 member: member.keys.public_key().to_hex(),
16676 role_ids: vec![access.role_id.clone()],
16677 }],
16678 roles: vec![access],
16679 };
16680 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
16681
16682 let stranger = Keys::generate().public_key().to_hex();
16684 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 9, &[0xBA; 32], &stranger).unwrap();
16685 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
16687 assert_eq!(
16688 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
16689 2,
16690 "the squatter never displaces the genuine vend — both are candidates"
16691 );
16692
16693 let session = crate::db::current_session();
16694 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16695 let adopted = absorb_parked_channel_keys(&reloaded, &session);
16696 assert_eq!(adopted.len(), 1, "exactly one adoption");
16697
16698 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16699 let ch = after.channel(&priv_id).unwrap();
16700 assert_eq!(ch.key, Some(real_key), "the OWNER's key won, not the squatter's");
16701 assert_eq!(ch.epoch, Epoch(1), "at the genuine epoch");
16702 assert!(
16703 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
16704 "and every candidate for the channel is discharged"
16705 );
16706 }
16707
16708 #[tokio::test]
16709 async fn revoking_without_a_folded_access_role_refuses_instead_of_evicting_everyone() {
16710 let (_tmp, _guard, _owner) = init_test_db();
16715 let relay = MemoryRelay::new();
16716 let community = create_community(&relay, "NoRole", vec!["wss://r".into()], None).await.unwrap();
16717 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
16718 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16719 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16720 let before = held.channel(&priv_id).unwrap().epoch;
16721
16722 crate::db::community::set_community_roles(&cid_hex, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
16726 let mut blind = held.clone();
16727 blind.relays = vec!["wss://empty".into()];
16728 let err = revoke_channel_access(&relay, &blind, &priv_id, &Keys::generate().public_key())
16729 .await
16730 .unwrap_err();
16731 assert!(err.contains("has not folded"), "refuses with a retryable reason: {err}");
16732
16733 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16734 assert_eq!(after.channel(&priv_id).unwrap().epoch, before, "and rotates nothing");
16735 }
16736
16737 async fn publish_base_rotation(
16744 relay: &MemoryRelay,
16745 community: &CommunityV2,
16746 rotator: &Keys,
16747 recipients: &[PublicKey],
16748 new_root: &[u8; 32],
16749 prev_key: &[u8; 32],
16750 ) {
16751 let new_epoch = Epoch(community.root_epoch.0 + 1);
16752 let prev_epoch = community.root_epoch;
16753 let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
16754 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
16755 let blobs: Vec<_> = recipients
16756 .iter()
16757 .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
16758 .collect();
16759 let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000, my_authority_citation(community, &rotator.public_key()).as_ref(), false).unwrap();
16760 for e in &events {
16761 relay.publish(e, &community.relays).await.unwrap();
16762 }
16763 }
16764
16765 async fn publish_severing_base_rotation(
16767 relay: &MemoryRelay,
16768 community: &CommunityV2,
16769 rotator: &Keys,
16770 recipients: &[PublicKey],
16771 new_root: &[u8; 32],
16772 prev_key: &[u8; 32],
16773 ) {
16774 let new_epoch = Epoch(community.root_epoch.0 + 1);
16775 let prev_epoch = community.root_epoch;
16776 let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
16777 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
16778 let blobs: Vec<_> = recipients
16779 .iter()
16780 .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
16781 .collect();
16782 let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000, my_authority_citation(community, &rotator.public_key()).as_ref(), true).unwrap();
16783 for e in &events {
16784 relay.publish(e, &community.relays).await.unwrap();
16785 }
16786 }
16787
16788 fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
16790 community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
16791 crate::db::community::save_community_v2(community).unwrap();
16792 }
16793
16794 #[tokio::test]
16795 async fn follow_rekeys_is_a_noop_without_rotations() {
16796 let (_tmp, _guard, _owner) = init_test_db();
16797 let relay = MemoryRelay::new();
16798 let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
16799 let session = crate::db::current_session();
16800 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
16801 assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
16802 }
16803
16804 #[tokio::test]
16805 async fn follow_rekeys_adopts_an_owner_base_rotation() {
16806 let (_tmp, _guard, owner) = init_test_db();
16807 let relay = MemoryRelay::new();
16808 let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
16809 let new_root = [0xB1; 32];
16810 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
16812
16813 let session = crate::db::current_session();
16814 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
16815 assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
16816 assert_eq!(updated.community_root, new_root, "adopted the fresh root");
16817 let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
16819 let general = updated.channels[0].id;
16820 let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
16821 assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
16822 }
16823
16824 #[tokio::test]
16825 async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
16826 let (_tmp, _guard, owner) = init_test_db();
16827 let relay = MemoryRelay::new();
16828 let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
16829 let priv_id = ChannelId([0x33; 32]);
16830 add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
16831
16832 let new_key = [0x55; 32];
16834 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
16835 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
16836 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(priv_id), Epoch(1), &new_key).unwrap();
16837 let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None, false).unwrap();
16838 for e in &events {
16839 relay.publish(e, &community.relays).await.unwrap();
16840 }
16841
16842 let session = crate::db::current_session();
16843 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
16844 let ch = updated.channel(&priv_id).unwrap();
16845 assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
16846 assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
16847 assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
16848 }
16849
16850 #[tokio::test]
16851 async fn a_whole_channel_fan_rotates_in_one_hop_without_crossing_keys() {
16852 let (_tmp, _guard, owner) = init_test_db();
16858 let relay = MemoryRelay::new();
16859 let mut community = create_community(&relay, "FanRot", vec!["wss://r".into()], None).await.unwrap();
16860
16861 let fan: Vec<(ChannelId, [u8; 32], [u8; 32])> = (0u8..12)
16864 .map(|i| (ChannelId([0x30 + i; 32]), [0x40 + i; 32], [0x80 + i; 32]))
16865 .collect();
16866 for (id, held, _) in &fan {
16867 add_private_channel(&mut community, *id, *held, Epoch(0));
16868 }
16869
16870 for (id, held, fresh) in &fan {
16871 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), held);
16872 let group = channel_rekey_group_key(&community.community_root, id, Epoch(1));
16873 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(*id), Epoch(1), fresh).unwrap();
16874 let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(*id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None, false).unwrap();
16875 for e in &events {
16876 relay.publish(e, &community.relays).await.unwrap();
16877 }
16878 }
16879
16880 let session = crate::db::current_session();
16881 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
16882 for (id, _, fresh) in &fan {
16883 let ch = updated.channel(id).expect("channel survived the fan");
16884 assert_eq!(ch.epoch, Epoch(1), "every channel in the fan advanced");
16885 assert_eq!(ch.key, Some(*fresh), "each channel adopted ITS OWN key, not a neighbour's");
16886 }
16887 }
16888
16889 #[tokio::test]
16890 async fn follow_rekeys_ignores_a_non_owner_rotation() {
16891 let (_tmp, _guard, _owner) = init_test_db();
16894 let relay = MemoryRelay::new();
16895 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
16896 let rogue = Keys::generate();
16897 publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
16898
16899 let session = crate::db::current_session();
16900 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
16901 assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
16902 }
16903
16904 #[tokio::test]
16905 async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
16906 let (_tmp, _guard, owner) = init_test_db();
16909 let relay = MemoryRelay::new();
16910 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
16911 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
16913
16914 let session = crate::db::current_session();
16915 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
16916 assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
16917 }
16918
16919 #[tokio::test]
16920 async fn follow_rekeys_holds_on_an_incomplete_rotation() {
16921 let (_tmp, _guard, owner) = init_test_db();
16924 let relay = MemoryRelay::new();
16925 let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
16926 let new_epoch = Epoch(1);
16927 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
16928 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
16929 let other = Keys::generate();
16931 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
16932 let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None, false).unwrap();
16933 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
16934 relay.publish(&wrap, &community.relays).await.unwrap();
16935
16936 let session = crate::db::current_session();
16937 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
16938 assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
16939 }
16940
16941 #[tokio::test]
16942 async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
16943 let (bed, owner, member) = TestBed::new();
16947 bed.swap_to(&owner);
16948 let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
16949 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
16950
16951 bed.swap_to(&member);
16952 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
16953 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
16954
16955 bed.swap_to(&owner);
16957 let stranger = Keys::generate();
16958 publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
16959
16960 bed.swap_to(&member);
16962 let session = crate::db::current_session();
16963 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
16964 assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
16965 assert!(follow.updated.is_none(), "a removed member adopts nothing");
16966 }
16967
16968 #[tokio::test]
16969 async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
16970 let (_tmp, _guard, owner) = init_test_db();
16976 let relay = MemoryRelay::new();
16977 let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
16978 let root0 = community.community_root;
16979 let priv_id = ChannelId([0x33; 32]);
16980 let key1 = [0x44; 32];
16981 add_private_channel(&mut community, priv_id, key1, Epoch(1));
16982
16983 let key2 = [0x55; 32];
16986 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
16987 let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
16988 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
16989 for e in rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &prev_commit, &[blob], 2_000, None, false).unwrap() {
16990 relay.publish(&e, &community.relays).await.unwrap();
16991 }
16992
16993 community.community_root = [0xB7; 32];
16997 community.root_epoch = Epoch(1);
16998 crate::db::community::save_community_v2(&community).unwrap();
16999
17000 let session = crate::db::current_session();
17001 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
17002 let ch = updated.channel(&priv_id).unwrap();
17003 assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
17004 assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
17005 }
17006
17007 #[tokio::test]
17008 async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
17009 let (_tmp, _guard, owner) = init_test_db();
17014 let relay = MemoryRelay::new();
17015 let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
17016 let priv_id = ChannelId([0x66; 32]);
17017 community.channels.push(ChannelV2 { id: priv_id, name: "vault".into(), private: true, key: None, epoch: Epoch(0), voice: None, meta_custom: None, meta_extra: Default::default() });
17018 crate::db::community::save_community_v2(&community).unwrap();
17019 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17020 assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
17021
17022 let stranger = Keys::generate();
17024 let key1 = [0x71; 32];
17025 let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
17026 let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
17027 let b1 = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &stranger.public_key(), RekeyScope::Channel(priv_id), Epoch(1), &key1).unwrap();
17028 for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None, false).unwrap() {
17029 relay.publish(&e, &community.relays).await.unwrap();
17030 }
17031 let key2 = [0x72; 32];
17034 let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17035 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
17036 let b2 = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
17037 for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None, false).unwrap() {
17038 relay.publish(&e, &community.relays).await.unwrap();
17039 }
17040
17041 let session = crate::db::current_session();
17044 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
17045 let ch = updated.channel(&priv_id).unwrap();
17046 assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
17047 assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
17048 }
17049
17050 #[tokio::test]
17051 async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
17052 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
17056 let (_tmp, _guard, _owner) = init_test_db();
17057 let relay = MemoryRelay::new();
17058 let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
17059 let priv_id = ChannelId([0x88; 32]);
17060 let key1 = [0x91; 32];
17061 add_private_channel(&mut community, priv_id, key1, Epoch(1));
17062
17063 let admin = Keys::generate();
17065 let role = Role::admin("aa".repeat(32));
17066 let roster = CommunityRoles {
17067 roles: vec![role.clone()],
17068 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
17069 };
17070 seed_roster_with_heads(&community, &roster, 1_000);
17071
17072 let key2 = [0x92; 32];
17074 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17075 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
17076 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
17077 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
17078 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, my_authority_citation(&community, &admin.public_key()).as_ref(), false).unwrap() {
17079 relay.publish(&e, &community.relays).await.unwrap();
17080 }
17081 let session = crate::db::current_session();
17082 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
17083 assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
17084
17085 let rogue = Keys::generate();
17087 let key3 = [0x93; 32];
17088 let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
17089 let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
17090 let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
17091 for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None, false).unwrap() {
17092 relay.publish(&e, &updated.relays).await.unwrap();
17093 }
17094 let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
17095 assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
17096 }
17097
17098 #[tokio::test]
17099 async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
17100 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
17105 let (bed, owner, member) = TestBed::new();
17106 bed.swap_to(&owner);
17107 let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
17108
17109 bed.swap_to(&member);
17112 let mut held = community.clone();
17113 let priv_id = ChannelId([0xAB; 32]);
17114 let key1 = [0xA1; 32];
17115 add_private_channel(&mut held, priv_id, key1, Epoch(1));
17116 let peer = Keys::generate();
17117 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17118 let role = Role::admin("bb".repeat(32));
17119 let roster = CommunityRoles {
17120 roles: vec![role.clone()],
17121 grants: vec![
17122 MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
17123 MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
17124 ],
17125 };
17126 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
17127
17128 let key2 = [0xA2; 32];
17130 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17131 let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
17132 let pb = rekey::build_blob_local(peer.secret_key(), &peer.public_key().to_bytes(), &peer.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
17133 for e in rekey::build_rekey_chunks_local(&peer, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[pb], 2_000, my_authority_citation(&held, &peer.public_key()).as_ref(), false).unwrap() {
17134 bed.relay.publish(&e, &held.relays).await.unwrap();
17135 }
17136 let session = crate::db::current_session();
17137 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
17138 assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
17139 let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
17140 assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
17141
17142 let key3 = [0xA3; 32];
17144 let stranger = Keys::generate();
17145 let ob = rekey::build_blob_local(owner.keys.secret_key(), &owner.keys.public_key().to_bytes(), &stranger.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key3).unwrap();
17146 for e in rekey::build_rekey_chunks_local(&owner.keys, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[ob], 2_100, None, false).unwrap() {
17147 bed.relay.publish(&e, &held.relays).await.unwrap();
17148 }
17149 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
17150 let updated = follow.updated.expect("the owner's removal folds");
17151 assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
17152 }
17153
17154 #[tokio::test]
17155 async fn converting_a_public_channel_to_private_is_refused() {
17156 let (_tmp, _guard, _owner) = init_test_db();
17159 let relay = MemoryRelay::new();
17160 let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
17161 let general = community.channels[0].id;
17162 let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
17163 let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
17164 assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
17165 let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
17167 edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
17168 }
17169
17170 async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
17173 let doc = super::super::list::CommunityList {
17174 entries: vec![],
17175 tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
17176 extra: Default::default(),
17177 };
17178 for (i, frag) in super::super::list_frag::fragment(&doc).iter().enumerate() {
17180 let event = super::super::list_frag::build_fragment_event_keys(me, frag, i, now_ms() / 1000).unwrap();
17181 relay.publish(&event, relays).await.unwrap();
17182 }
17183 }
17184
17185 #[tokio::test]
17186 async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
17187 let (_tmp, _guard, me) = init_test_db();
17191 let relay = MemoryRelay::new();
17192 let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
17193 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
17194
17195 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
17197
17198 let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
17200 republish_community_list(&relay, Some(y.id())).await.unwrap();
17201
17202 let list = fetch_fragments(&relay, &x.relays).await.unwrap().unwrap().list;
17204 assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
17205 assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
17206 }
17207
17208 #[tokio::test]
17209 async fn sync_tears_down_a_community_a_sibling_left() {
17210 let (_tmp, _guard, me) = init_test_db();
17213 let relay = MemoryRelay::new();
17214 let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
17215 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
17216 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
17217
17218 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
17219 sync_community_list(&relay, &x.relays).await.unwrap();
17220 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
17221 }
17222
17223 #[tokio::test]
17224 async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
17225 let (_tmp, _guard, me) = init_test_db();
17228 let relay = MemoryRelay::new();
17229 let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
17230 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
17231 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
17233 republish_community_list(&relay, Some(x.id())).await.unwrap();
17235 sync_community_list(&relay, &x.relays).await.unwrap();
17236 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
17237 }
17238
17239 #[tokio::test]
17240 async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
17241 let (_tmp, _guard, _me) = init_test_db();
17244 let good = MemoryRelay::new();
17245 let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
17246 assert!(fetch_fragments(&good, &community.relays).await.unwrap().is_some());
17247
17248 struct FetchErrors;
17250 #[async_trait::async_trait]
17251 impl Transport for FetchErrors {
17252 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
17253 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
17254 panic!("republish must NOT publish when the remote fetch failed");
17255 }
17256 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
17257 Ok(())
17258 }
17259 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
17260 Err("relay unreachable".to_string())
17261 }
17262 }
17263 republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
17265 }
17266
17267 #[tokio::test]
17268 async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
17269 let (_tmp, _guard, owner) = init_test_db();
17275 let relay = MemoryRelay::new();
17276 let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
17277
17278 let lurker = Keys::generate();
17280 let rid = "b1".repeat(32);
17281 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
17282 publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
17283
17284 let members = memberlist(&relay, &community).await.unwrap();
17286 assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
17287
17288 let banned_grantee = Keys::generate();
17290 publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
17291 set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
17292 let members = memberlist(&relay, &community).await.unwrap();
17293 assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
17294 assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
17295
17296 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
17298 assert_eq!(refounded.root_epoch, Epoch(1));
17299 let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
17300 let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
17301 let rotations = rekey::collect_rotations(&chunks);
17302 let lurker_x = lurker.public_key().to_bytes();
17303 let delivered = rotations.iter().any(|r| {
17304 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
17305 });
17306 assert!(delivered, "the Refounding delivered the new root to the granted lurker");
17307 }
17308
17309 #[tokio::test]
17310 async fn the_memberlist_pages_past_a_guestbook_flood() {
17311 let (_tmp, _guard, _owner) = init_test_db();
17315 let relay = MemoryRelay::new();
17316 let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
17317 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
17318
17319 let honest = Keys::generate();
17321 let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
17322 let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
17323 relay.publish(&w, &community.relays).await.unwrap();
17324 for i in 0..600u64 {
17325 let throwaway = Keys::generate();
17326 let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
17327 let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
17328 relay.publish(&w, &community.relays).await.unwrap();
17329 }
17330
17331 let members = memberlist(&relay, &community).await.unwrap();
17332 assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
17333 }
17334
17335 #[tokio::test]
17336 async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
17337 let (_tmp, _guard, owner) = init_test_db();
17342 let relay = MemoryRelay::new();
17343 let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
17344 let new_root = [0xD9; 32];
17345 let new_epoch = Epoch(1);
17346 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
17347
17348 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
17350
17351 let rogue = Keys::generate();
17354 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
17355 for i in 0..260u64 {
17356 let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
17357 let rumor = rekey::build_rekey_rumor(rogue.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 1, 3_000 + i, None, false).unwrap();
17358 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
17359 relay.publish(&wrap, &community.relays).await.unwrap();
17360 }
17361
17362 let session = crate::db::current_session();
17363 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
17364 assert_eq!(updated.root_epoch, Epoch(1));
17365 assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
17366 }
17367
17368 #[tokio::test]
17369 async fn a_swap_during_create_private_channel_lands_in_the_creating_account() {
17370 let (bed, owner, _member) = TestBed::new();
17379 bed.swap_to(&owner);
17380 let creator = crate::db::current_session();
17381 let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
17382 let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
17383
17384 let swap_relay = SwapMidPublish { inner: MemoryRelay::new() };
17386 create_private_channel(&swap_relay, &community, "ghost").await.unwrap();
17387
17388 let mine = crate::db::with_session(creator, async {
17389 crate::db::community::load_community_v2(community.id()).unwrap().unwrap()
17390 })
17391 .await;
17392 assert_eq!(mine.channels.len(), before + 1, "the channel is minted for the account that asked");
17393 assert!(mine.channels.iter().any(|c| c.name == "ghost"), "and it is the one requested");
17394 }
17395
17396 #[tokio::test]
17397 async fn an_uncited_admin_rotation_is_not_adopted() {
17398 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
17403 let (_tmp, _guard, _owner) = init_test_db();
17404 let relay = MemoryRelay::new();
17405 let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
17406 let priv_id = ChannelId([0x8A; 32]);
17407 let key1 = [0x93; 32];
17408 add_private_channel(&mut community, priv_id, key1, Epoch(1));
17409
17410 let admin = Keys::generate();
17411 let role = Role::admin("cf".repeat(32));
17412 let roster = CommunityRoles {
17413 roles: vec![role.clone()],
17414 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
17415 };
17416 seed_roster_with_heads(&community, &roster, 1_000);
17417
17418 let key2 = [0x94; 32];
17419 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17420 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
17421 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
17422 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
17423 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None, false).unwrap() {
17425 relay.publish(&e, &community.relays).await.unwrap();
17426 }
17427
17428 let out = follow_rekeys(&relay, &community, &crate::db::current_session()).await.unwrap();
17429 assert!(out.updated.is_none(), "an uncited rotation is not adopted");
17430
17431 let cited = my_authority_citation(&community, &admin.public_key());
17434 assert!(cited.is_some(), "the seeded head yields a citation");
17435 let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
17436 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob2], 2_100, cited.as_ref(), false).unwrap() {
17437 relay.publish(&e, &community.relays).await.unwrap();
17438 }
17439 let out = follow_rekeys(&relay, &community, &crate::db::current_session()).await.unwrap();
17440 assert!(out.updated.is_some(), "the cited rotation IS adopted");
17441 }
17442
17443 #[tokio::test]
17444 async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
17445 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
17453 let (_tmp, _guard, _owner) = init_test_db();
17454 let relay = MemoryRelay::new();
17455 let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
17456 let priv_id = ChannelId([0xC0; 32]);
17457 let key1 = [0xC1; 32];
17458 add_private_channel(&mut community, priv_id, key1, Epoch(1));
17459
17460 let (a, b) = (Keys::generate(), Keys::generate());
17462 let role = Role::admin("ce".repeat(32));
17463 let roster = CommunityRoles {
17464 roles: vec![role.clone()],
17465 grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
17466 };
17467 seed_roster_with_heads(&community, &roster, 1_000);
17468
17469 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
17472 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17473 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
17474 let key_a = [0x0A; 32];
17475 let key_b = [0xFB; 32]; for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
17477 let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
17478 for e in rekey::build_rekey_chunks_local(signer, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, my_authority_citation(&community, &signer.public_key()).as_ref(), false).unwrap() {
17479 relay.publish(&e, &community.relays).await.unwrap();
17480 }
17481 }
17482
17483 let session = crate::db::current_session();
17484 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
17485 let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
17486 assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
17487
17488 let mut peer = community.clone();
17490 if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
17491 c.key = Some(key1);
17492 c.epoch = Epoch(1);
17493 }
17494 let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
17496 assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
17497 }
17498
17499 #[tokio::test]
17500 async fn create_private_channel_refuses_a_member_without_manage_channels() {
17501 let (bed, owner, member) = TestBed::new();
17504 bed.swap_to(&owner);
17505 let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
17506 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
17507
17508 bed.swap_to(&member);
17509 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
17510 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
17511 let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
17512 assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
17513 let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
17514 assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
17515 }
17516
17517 #[tokio::test]
17520 async fn accept_rejects_a_bundle_with_a_forged_community_root() {
17521 let (bed, owner, member) = TestBed::new();
17525 bed.swap_to(&owner);
17526 let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
17527
17528 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
17529 let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
17530 forged.community_root = fake.clone();
17531 for ch in &mut forged.channels {
17532 ch.key = fake.clone();
17533 }
17534 let attacker = Keys::generate();
17535 let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
17536 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
17537
17538 bed.swap_to(&member);
17539 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
17540 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
17541 assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
17542 assert!(
17543 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
17544 "a rejected join persists nothing"
17545 );
17546 }
17547
17548 #[tokio::test]
17549 async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
17550 let (bed, owner, member) = TestBed::new();
17555 bed.swap_to(&owner);
17556 let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
17557 let general = community.channels[0].id;
17558
17559 let rotated = rotate_view(&community, [0x5A; 32], 1);
17560 let admin = Keys::generate();
17561 publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
17562 publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
17563
17564 bed.swap_to(&member);
17565 let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
17566 let joined = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
17567 assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
17568 }
17569
17570 #[tokio::test]
17571 async fn only_an_actual_join_publishes_a_guestbook_join() {
17572 let (bed, owner, member) = TestBed::new();
17576 bed.swap_to(&owner);
17577 let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
17578
17579 let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
17580 async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
17581 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
17582 relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
17583 }
17584 let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await; bed.swap_to(&member);
17587 let bundle = bundle_of(&community, BundleAudience::Link, None, None, None);
17588 accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
17589 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
17590
17591 accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
17592 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
17593
17594 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17595 crate::db::community::delete_community(&cid_hex).unwrap();
17596 async fn list_id(relay: &MemoryRelay, me: &PublicKey, relays: &[String]) -> Option<String> {
17604 let q = Query { kinds: vec![crate::community::v2::kind::COMMUNITY_LIST_FRAG], authors: vec![me.to_hex()], ..Default::default() };
17605 let mut evs = relay.fetch(&q, relays).await.unwrap_or_default();
17606 evs.sort_by_key(|e| e.created_at.as_secs());
17607 evs.last().map(|e| e.id.to_hex())
17608 }
17609 let my_pk = me_pk().unwrap();
17610 let list_before = list_id(&bed.relay, &my_pk, &bed.relays).await;
17611 accept_bundle(&bed.relay, &bundle, None, false).await.unwrap();
17612 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
17613 assert_eq!(
17614 list_id(&bed.relay, &my_pk, &bed.relays).await,
17615 list_before,
17616 "adopting an entry FROM the list must not republish the list"
17617 );
17618
17619 crate::db::community::delete_community(&cid_hex).unwrap();
17622 accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
17623 assert_ne!(
17624 list_id(&bed.relay, &my_pk, &bed.relays).await,
17625 list_before,
17626 "a real join still records the membership across devices"
17627 );
17628 }
17629
17630 #[tokio::test]
17631 async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
17632 let (bed, owner, member) = TestBed::new();
17635 bed.swap_to(&owner);
17636 let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
17637
17638 let rotated = rotate_view(&community, [0x5B; 32], 1);
17639 let attacker = Keys::generate();
17640 publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
17641
17642 bed.swap_to(&member);
17643 let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
17644 let err = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap_err();
17645 assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
17646 }
17647
17648 #[tokio::test]
17649 async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
17650 let (bed, owner, member) = TestBed::new();
17653 bed.swap_to(&owner);
17654 let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
17655 let general = community.channels[0].id;
17656
17657 let mut fake = community.clone();
17658 fake.community_root = [0x5C; 32]; let admin = Keys::generate();
17660 publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
17661 publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
17662
17663 bed.swap_to(&member);
17664 let bundle = bundle_of(&fake, BundleAudience::Link, None, None, None);
17665 let err = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap_err();
17666 assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
17667 }
17668
17669 #[tokio::test]
17670 async fn follow_control_heals_a_bundle_misclassified_public_channel() {
17671 let (_tmp, _guard, _owner) = init_test_db();
17675 let relay = MemoryRelay::new();
17676 let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
17677 let general = community.channels[0].id;
17678 let mut poisoned = community.clone();
17679 poisoned.channels[0].private = true;
17680 poisoned.channels[0].key = Some([0x66; 32]);
17681 crate::db::community::save_community_v2(&poisoned).unwrap();
17682
17683 let healed = follow_control(&relay, &poisoned).await.unwrap().updated.expect("healed");
17684 let ch = healed.channel(&general).unwrap();
17685 assert!(!ch.private, "the owner's public declaration overrides the bundle");
17686 assert_eq!(ch.key, None, "a healed public channel derives from the root");
17687 }
17688
17689 #[tokio::test]
17690 async fn a_deleted_channel_does_not_resurrect_on_reload() {
17691 let (_tmp, _guard, owner) = init_test_db();
17694 let relay = MemoryRelay::new();
17695 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
17696 let extra = ChannelId([0x77; 32]);
17697 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
17698 let with_extra = follow_control(&relay, &community).await.unwrap().updated.unwrap();
17699 assert!(with_extra.channel(&extra).is_some());
17700 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
17701 let after = follow_control(&relay, &with_extra).await.unwrap().updated.unwrap();
17702 assert!(after.channel(&extra).is_none());
17703
17704 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17705 assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
17706 assert_eq!(reloaded.channels.len(), 1);
17707 }
17708
17709 #[tokio::test]
17710 async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
17711 let (_tmp, _guard, _owner) = init_test_db();
17715 let relay = MemoryRelay::new();
17716 let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
17717 let a_channel = a.channels[0].id;
17718 let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
17719 let b_channel = b.channels[0].id;
17720 b.channels.push(ChannelV2 { id: a_channel, name: "phantom".into(), private: false, key: None, epoch: b.root_epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
17722
17723 crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
17724 let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
17726 assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
17727 assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
17728 let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
17730 assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
17731 assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
17732 }
17733
17734 struct CappedRelay {
17740 events: Vec<Event>,
17741 cap: usize,
17742 }
17743 #[async_trait::async_trait]
17744 impl Transport for CappedRelay {
17745 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
17746 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
17747 Ok(())
17748 }
17749 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
17750 Ok(())
17751 }
17752 async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
17753 let mut m: Vec<Event> = self
17754 .events
17755 .iter()
17756 .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
17757 .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
17758 .cloned()
17759 .collect();
17760 m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
17762 Ok(m)
17763 }
17764 }
17765
17766 #[tokio::test]
17767 async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
17768 let (_tmp, _guard, _owner) = init_test_db();
17775 let memory = MemoryRelay::new();
17776 let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
17777 let control = control::ControlPlane::of(&community).write_group().unwrap();
17781
17782 let rogue = Keys::generate();
17783 let mut events: Vec<Event> = Vec::new();
17784 for i in 0..FOLLOW_PAGE {
17785 let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
17786 let rumor = control::build_edition_rumor(
17787 rogue.public_key(),
17788 vsk::CHANNEL_METADATA,
17789 &[0xAB; 32],
17790 1,
17791 None,
17792 &content,
17793 9_000,
17794 None,
17795 );
17796 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
17797 events.push(w);
17798 }
17799 let relay = CappedRelay { events, cap: FOLLOW_PAGE };
17800
17801 let err = refound_community(&relay, &community, &[])
17802 .await
17803 .expect_err("a plane that can't be read whole must never be compacted");
17804 assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
17805 }
17806
17807 #[tokio::test]
17808 async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
17809 let (_tmp, _guard, owner) = init_test_db();
17814 let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
17815 let g = control::genesis(&owner, meta, 1_000).unwrap();
17816 let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
17817
17818 let control = control::ControlPlane::of(&community).write_group().unwrap();
17819 let rogue = Keys::generate();
17820 let mut events: Vec<Event> = g.wraps.to_vec();
17821 for i in 0..250u64 {
17822 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
17823 let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
17824 events.push(wrap);
17825 }
17826 let relay = CappedRelay { events, cap: 100 };
17828 let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
17829 assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
17830 }
17831
17832 #[tokio::test]
17833 async fn accept_parked_invite_joins_from_the_stored_bundle() {
17834 let (bed, owner, member) = TestBed::new();
17837 bed.swap_to(&owner);
17838 let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
17839 let general = community.channels[0].id;
17840 send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
17841 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
17842 let bundle_json = serde_json::to_string(&bundle).unwrap();
17843 let inviter_hex = owner.keys.public_key().to_hex();
17844
17845 bed.swap_to(&member);
17846 let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
17847 assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
17848 assert!(joined.identity.verify());
17849 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
17850 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
17853 assert!(
17854 crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
17855 "the joiner's control floor is seeded from the join-time fold"
17856 );
17857
17858 bed.swap_to(&owner);
17860 let members = memberlist(&bed.relay, &community).await.unwrap();
17861 assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
17862 }
17863
17864 #[tokio::test]
17865 async fn accept_parked_invite_rejects_a_forged_root() {
17866 let (_tmp, _guard, _owner) = init_test_db();
17870 let relay = MemoryRelay::new();
17871 let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
17872 let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
17873 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
17874 forged.community_root = fake.clone();
17875 for ch in &mut forged.channels {
17876 ch.key = fake.clone();
17877 }
17878 let bundle_json = serde_json::to_string(&forged).unwrap();
17879
17880 let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
17881 assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
17882 }
17883
17884 #[test]
17885 fn v2_and_v1_bundles_are_distinguishable_by_parse() {
17886 let owner = Keys::generate();
17890 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
17891 let hex = crate::simd::hex::bytes_to_hex_32;
17892 let v2 = invite::CommunityInvite {
17893 community_id: hex(&identity.community_id.0),
17894 owner: hex(&identity.owner_xonly),
17895 owner_salt: hex(&identity.owner_salt),
17896 community_root: hex(&[0x11; 32]),
17897 root_epoch: 0,
17898 control_pk: None,
17899 channels: vec![],
17900 relays: vec!["wss://r".into()],
17901 name: "V2".into(),
17902 icon: None,
17903 expires_at: None,
17904 creator_npub: None,
17905 label: None,
17906 extra: Default::default(),
17907 };
17908 let v2_json = serde_json::to_string(&v2).unwrap();
17909 assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
17910 let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
17911 assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
17912 }
17913
17914 #[tokio::test]
17915 async fn verify_rejects_a_cross_community_owner_edition_replay() {
17916 let (_tmp, _guard, owner) = init_test_db();
17922
17923 let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
17925 let x_control = control::split_write_group(&gx.control_root, &gx.community_root, &gx.identity.community_id, Epoch(0));
17926 let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
17927
17928 let t_identity = control::CommunityIdentity::mint(&owner.public_key());
17930 let fake_root = [0xEE; 32];
17931 let t = CommunityV2 {
17932 identity: t_identity,
17933 community_root: fake_root,
17934 root_epoch: Epoch(0),
17935 control_pk: None,
17936 control_root: None,
17937 name: "T".into(),
17938 description: None,
17939 icon: None,
17940 banner: None,
17941 meta_custom: None,
17942 meta_extra: Default::default(),
17943 relays: vec!["wss://r".into()],
17944 channels: vec![],
17945 dissolved: false,
17946 created_at_ms: 0,
17947 };
17948 let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
17951 let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
17952 let relay = MemoryRelay::new();
17953 relay.publish(&replayed, &t.relays).await.unwrap();
17954
17955 let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
17956 assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
17957 }
17958
17959 #[tokio::test]
17967 #[ignore = "hits a real relay over the network"]
17968 async fn live_smoke_create_send_fetch_on_a_real_relay() {
17969 use crate::community::transport::LiveTransport;
17970 use nostr_sdk::prelude::ToBech32;
17971
17972 let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
17973 let relays = vec![relay.clone()];
17974
17975 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
17977 crate::db::close_database();
17978 crate::db::clear_id_caches();
17979 let tmp = tempfile::tempdir().unwrap();
17980 let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
17983 Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
17984 Err(_) => Keys::generate(),
17985 };
17986 let npub = keys.public_key().to_bech32().unwrap();
17987 if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
17990 println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
17991 }
17992 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
17993 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
17994 crate::db::set_current_account(npub.clone()).unwrap();
17995 crate::db::init_database(&npub).unwrap();
17996 crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
17997 crate::state::set_my_public_key(keys.public_key());
17998 println!("[smoke] throwaway identity {npub}");
17999
18000 let client = crate::nostr_client_builder().build();
18002 client.add_managed_relay(relay.as_str()).await.ok();
18003 client.connect().await;
18004 crate::state::set_nostr_client(client);
18005 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
18006
18007 let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
18009 let general = community.channels[0].id;
18010 println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
18011
18012 let text = "hello from a Vector Concord v2 live smoke test";
18013 let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
18014 println!("[smoke] sent message {sent_id}");
18015
18016 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
18018
18019 let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
18020 let texts: Vec<String> = page
18021 .iter()
18022 .filter_map(|f| match &f.event {
18023 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
18024 _ => None,
18025 })
18026 .collect();
18027 println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
18028 assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
18029
18030 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
18032 println!("[smoke] invite link: {}", link.url);
18033 println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
18034 }
18035
18036 #[tokio::test]
18037 async fn chat_ops_react_edit_delete_round_trip() {
18038 let (bed, owner, _member) = TestBed::new();
18039 bed.swap_to(&owner);
18040 let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
18041 let general = community.channels[0].id;
18042 let me_hex = owner.keys.public_key().to_hex();
18043
18044 let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
18045 send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
18046 .await
18047 .unwrap();
18048 send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
18049 send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
18050
18051 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18052 let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
18053 let mut saw = (false, false, false);
18054 for f in &page {
18055 match &f.event {
18056 ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
18057 assert_eq!(emoji, ":fire:");
18058 assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
18059 saw.0 = true;
18060 }
18061 ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
18062 assert_eq!(new_content, "edited");
18063 saw.1 = true;
18064 }
18065 ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
18066 _ => {}
18067 }
18068 }
18069 assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
18070 }
18071
18072 #[tokio::test]
18073 async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
18074 let (bed, owner, _member) = TestBed::new();
18075 bed.swap_to(&owner);
18076 let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
18077 let general = community.channels[0].id;
18078 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
18079
18080 let mut sub = bed.relay.subscribe(Query {
18082 kinds: vec![stream::KIND_WRAP_EPHEMERAL],
18083 authors: vec![group.pk_hex()],
18084 ..Default::default()
18085 });
18086 send_typing(&bed.relay, &community, &general).await.unwrap();
18087 let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
18088 let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
18089 Ok(ChatEvent::Typing { opened }) => opened,
18090 other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
18091 };
18092
18093 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18096 assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
18097
18098 assert!(
18100 crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
18101 "ephemeral sends must not retain scrub keys"
18102 );
18103 }
18104
18105 #[tokio::test]
18106 async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
18107 let (bed, owner, _member) = TestBed::new();
18108 bed.swap_to(&owner);
18109 let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
18110 let general = community.channels[0].id;
18111 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
18112
18113 let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
18114
18115 let (keys, outer_hex, relays) =
18118 crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
18119 assert_eq!(relays, community.relays);
18120 let wrap_query = Query {
18121 kinds: vec![stream::KIND_WRAP],
18122 authors: vec![group.pk_hex()],
18123 ..Default::default()
18124 };
18125 let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
18126 let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
18127 assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
18128
18129 let me_hex = owner.keys.public_key().to_hex();
18131 let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
18132 .await
18133 .unwrap();
18134 assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
18135
18136 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
18139 assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
18140 let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
18141 assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
18142 }
18143
18144 #[tokio::test]
18145 async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
18146 let (bed, owner, _member) = TestBed::new();
18147 bed.swap_to(&owner);
18148 let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
18149 let general = community.channels[0].id;
18150 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
18151
18152 let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
18155 crate::db::community::delete_message_key(&id).unwrap();
18156 assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
18157
18158 let mkeys = Keys::generate();
18160 let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
18161 let foreign_id = rumor.id.unwrap().to_hex();
18162 let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
18163 bed.relay.publish(&fw, &community.relays).await.unwrap();
18164
18165 fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18167 let (keys, _outer, relays) =
18168 crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
18169 assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
18170 assert_eq!(relays, community.relays);
18171
18172 assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
18174
18175 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
18177 let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18178 assert!(
18179 !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
18180 "healed message scrubbed from the relay"
18181 );
18182 }
18183
18184 #[tokio::test]
18185 async fn send_chat_message_threads_the_reply_and_extra_tags() {
18186 let (bed, owner, _member) = TestBed::new();
18187 bed.swap_to(&owner);
18188 let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
18189 let general = community.channels[0].id;
18190 let me_hex = owner.keys.public_key().to_hex();
18191
18192 let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
18193 let imeta = nostr_sdk::prelude::Tag::custom(
18194 "imeta",
18195 ["url https://e/blob".to_string(), "m image/png".to_string()],
18196 );
18197 let child_id = send_chat_message(
18198 &bed.relay, &community, &general, "child",
18199 Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
18200 )
18201 .await
18202 .unwrap();
18203
18204 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18205 let child = page
18206 .iter()
18207 .find_map(|f| match &f.event {
18208 ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
18209 _ => None,
18210 })
18211 .expect("the reply message round-trips");
18212 let reply = child.1.as_ref().expect("the reply reference is carried");
18213 assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
18214 assert_eq!(reply.author, Some(owner.keys.public_key()));
18215 assert!(
18216 child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
18217 "the imeta attachment tag rides the rumor verbatim"
18218 );
18219 }
18220
18221 #[tokio::test]
18222 async fn a_kick_needs_kick_authority_and_removes_the_target() {
18223 let (bed, owner, member) = TestBed::new();
18224 bed.swap_to(&owner);
18225 let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
18226
18227 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
18229 let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
18230 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
18231 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
18232 let before = memberlist(&bed.relay, &community).await.unwrap();
18233 assert!(before.contains(&member.keys.public_key()), "the join lands first");
18234
18235 bed.swap_to(&member);
18237 let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
18238 assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
18239
18240 bed.swap_to(&owner);
18242 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
18243 let after = memberlist(&bed.relay, &community).await.unwrap();
18244 assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
18245 assert!(after.contains(&owner.keys.public_key()), "the owner remains");
18246 }
18247
18248 #[tokio::test]
18249 async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
18250 let (bed, owner, member) = TestBed::new();
18255 bed.swap_to(&owner);
18256 let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
18257 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18258 let (o, m) = (owner.keys.public_key(), member.keys.public_key());
18259 let join = |at: u64, id: u8| guestbook::GuestbookEvent { epoch: None, observed_at: None,
18260 rumor_id: [id; 32],
18261 entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
18262 };
18263 let kick = |at: u64, id: u8| guestbook::GuestbookEvent { epoch: None, observed_at: None,
18264 rumor_id: [id; 32],
18265 entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
18266 };
18267
18268 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
18270 assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
18271
18272 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
18274 assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
18275
18276 crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
18278 assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
18279
18280 assert!(
18283 !stored_memberlist(&community).unwrap().contains(&m),
18284 "the memberlist excludes an un-caught-up member — why it can't gate a kick"
18285 );
18286 }
18287
18288 fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
18293 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18294 crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
18295 for g in &roster.grants {
18296 let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
18297 let eid = super::super::derive::grant_locator(community.id(), &m);
18298 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
18299 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
18300 }
18301 }
18302
18303 async fn publish_grant_citing(
18305 relay: &MemoryRelay,
18306 community: &CommunityV2,
18307 signer: &Keys,
18308 member: &PublicKey,
18309 role_ids: Vec<String>,
18310 version: u64,
18311 citation: Option<&crate::community::edition::AuthorityCitation>,
18312 ) {
18313 let group = control::ControlPlane::of(&community).write_group().unwrap();
18314 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
18315 let prev = head_hash_on_relay(relay, community, &eid).await;
18316 let grant = MemberGrant { member: member.to_hex(), role_ids };
18317 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
18318 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
18319 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
18320 relay.publish(&wrap, &community.relays).await.unwrap();
18321 }
18322
18323 #[tokio::test]
18324 async fn an_uncited_edition_cannot_self_promote_or_outlive_a_demotion() {
18325 let (bed, owner, admin) = TestBed::new();
18329 bed.swap_to(&owner);
18330 let community = create_community(&bed.relay, "NoEscalate", bed.relays.clone(), None).await.unwrap();
18331 let admin_pk = admin.keys.public_key();
18332
18333 let mid = "d1".repeat(32);
18335 let mut mid_role = admin_role(&mid, Permissions::admin().0);
18336 mid_role.position = 3;
18337 publish_role(&bed.relay, &community, &owner.keys, &mid_role, 1).await;
18338 let high = "d2".repeat(32);
18339 let mut high_role = admin_role(&high, Permissions::admin().0);
18340 high_role.position = 1;
18341 publish_role(&bed.relay, &community, &owner.keys, &high_role, 1).await;
18342 publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![mid.clone()], 1).await;
18343
18344 publish_grant_citing(&bed.relay, &community, &admin.keys, &admin_pk, vec![high.clone(), mid.clone()], 2, None).await;
18346 let view = fetch_authority(&bed.relay, &community).await;
18349 assert!(
18350 !view.roles.grants.iter().any(|g| g.member == admin_pk.to_hex() && g.role_ids.contains(&high)),
18351 "an uncited edition must not let an author grant themselves a role they do not \
18352 outrank — you cannot act at or above your own rank, citation or not",
18353 );
18354
18355 let bystander = Keys::generate().public_key();
18359 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![mid.clone()], 1, None).await;
18360 let before = fetch_authority(&bed.relay, &community).await;
18361 assert!(
18362 !before.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
18363 "an admin at position 3 cannot grant position 3 — equal cannot act on equal",
18364 );
18365
18366 publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![], 3).await;
18367 let after = fetch_authority(&bed.relay, &community).await;
18368 assert!(
18369 !after.roles.is_admin(&admin_pk.to_hex()),
18370 "the owner's demotion folds",
18371 );
18372 assert!(
18373 !after.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
18374 "and nothing the demoted author wrote is honored afterwards — authority is \
18375 resolved against the CURRENT roster, which is what the citation was only ever \
18376 a scheduling hint for",
18377 );
18378 }
18379
18380 #[tokio::test]
18381 async fn an_uncited_edition_folds_on_rank_alone_and_never_escalates() {
18382 let (bed, owner, admin) = TestBed::new();
18393 bed.swap_to(&owner);
18394 let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
18395 let admin_pk = admin.keys.public_key();
18396 let rid = "c3".repeat(32);
18397 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
18398 publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
18399
18400 let low_rid = "c4".repeat(32);
18402 let mut low = admin_role(&low_rid, Permissions::admin().0);
18403 low.position = 5;
18404 publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
18405
18406 let bystander = Keys::generate().public_key();
18408 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
18409 let view = fetch_authority(&bed.relay, &community).await;
18410 assert!(
18411 view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
18412 "an uncited edition from an author the roster DOES authorize, acting beneath \
18413 their own rank, is genuine and must fold",
18414 );
18415 assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant still folds");
18416
18417 let stranger = Keys::generate();
18419 let victim = Keys::generate().public_key();
18420 publish_grant_citing(&bed.relay, &community, &stranger, &victim, vec![low_rid.clone()], 1, None).await;
18421 let view = fetch_authority(&bed.relay, &community).await;
18422 assert!(
18423 !view.roles.grants.iter().any(|g| g.member == victim.to_hex()),
18424 "an uncited edition from an UNAUTHORIZED author must never fold — dropping the \
18425 citation requirement must not become a way in",
18426 );
18427
18428 let peer = Keys::generate().public_key();
18431 publish_grant_citing(&bed.relay, &community, &admin.keys, &peer, vec![rid.clone()], 1, None).await;
18432 let view = fetch_authority(&bed.relay, &community).await;
18433 assert!(
18434 !view.roles.grants.iter().any(|g| g.member == peer.to_hex()),
18435 "an authorized author still cannot grant AT their own rank — rank is what \
18436 stops escalation, and it is untouched",
18437 );
18438
18439 publish_grant_citing(&bed.relay, &community, &stranger, &bystander, vec![], 9, None).await;
18444 let view = fetch_authority(&bed.relay, &community).await;
18445 assert!(
18446 view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
18447 "an uncited empty grant must NOT strip a member — a revocation's rank check is \
18448 vacuous, so tolerating it uncited hands any stranger a demotion primitive",
18449 );
18450 }
18451
18452 #[tokio::test]
18453 async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
18454 let (bed, owner, member) = TestBed::new();
18459 bed.swap_to(&owner);
18460 let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
18461 let member_pk = member.keys.public_key();
18462 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18463
18464 crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
18466 crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
18467
18468 let join = guestbook::GuestbookEvent { epoch: None, observed_at: None,
18470 rumor_id: [9u8; 32],
18471 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
18472 };
18473 assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
18474 assert!(
18475 !stored_memberlist(&community).unwrap().contains(&member_pk),
18476 "while banned, the fold keeps them out"
18477 );
18478
18479 crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
18481 assert!(
18482 stored_memberlist(&community).unwrap().contains(&member_pk),
18483 "after the unban the raced Join makes them a member again"
18484 );
18485 }
18486
18487 #[tokio::test]
18488 async fn a_stale_root_admin_write_is_refused_not_misdirected() {
18489 let (bed, owner, member) = TestBed::new();
18495 bed.swap_to(&owner);
18496 let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
18497 let member_pk = member.keys.public_key();
18498
18499 set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
18500 let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
18501
18502 let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
18504 assert!(err.contains("re-founded"), "unban: {err}");
18505 let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
18507 assert!(err.contains("re-founded"), "invite: {err}");
18508 let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
18510 assert!(err.contains("re-founded"), "kick: {err}");
18511
18512 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18514 set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
18515 let view = fetch_authority(&bed.relay, &fresh).await;
18516 assert!(view.banned.is_empty(), "the retried unban actually unbans");
18517 }
18518
18519 struct YieldyRelay<'a>(&'a MemoryRelay);
18524 #[async_trait::async_trait]
18525 impl Transport for YieldyRelay<'_> {
18526 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
18527 tokio::task::yield_now().await;
18528 self.0.publish(event, relays).await
18529 }
18530 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
18531 tokio::task::yield_now().await;
18532 self.0.fetch(query, relays).await
18533 }
18534 async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
18535 tokio::task::yield_now().await;
18536 self.0.fetch_plane(plane, query, relays).await
18537 }
18538 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
18539 tokio::task::yield_now().await;
18540 self.0.publish_durable(event, relays).await
18541 }
18542 }
18543
18544 #[tokio::test]
18545 async fn concurrent_bans_compose_instead_of_erasing_each_other() {
18546 let (bed, owner, _member) = TestBed::new();
18551 bed.swap_to(&owner);
18552 let community = create_community(&bed.relay, "Wave", bed.relays.clone(), None).await.unwrap();
18553 let a = Keys::generate().public_key();
18554 let b = Keys::generate().public_key();
18555
18556 let yieldy = YieldyRelay(&bed.relay);
18557 let (wave_a, wave_b) = ([a], [b]);
18558 let (ra, rb) = tokio::join!(
18559 set_members_banned(&yieldy, community.id(), &wave_a, true),
18560 set_members_banned(&yieldy, community.id(), &wave_b, true),
18561 );
18562 ra.unwrap();
18563 rb.unwrap();
18564
18565 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18566 assert_eq!(fresh.root_epoch, Epoch(2), "each single ban still performed its own read cut");
18567 let view = fetch_authority(&bed.relay, &fresh).await;
18568 assert!(
18569 view.banned.contains(&a.to_hex()) && view.banned.contains(&b.to_hex()),
18570 "both racing bans landed: {:?}",
18571 view.banned
18572 );
18573 }
18574
18575 #[tokio::test]
18576 async fn bans_stacked_behind_a_rotation_coalesce_into_one_read_cut() {
18577 let (bed, owner, _member) = TestBed::new();
18582 bed.swap_to(&owner);
18583 let community = create_community(&bed.relay, "Stack", bed.relays.clone(), None).await.unwrap();
18584 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18585 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
18586 let (a, b, c, d) = (
18587 Keys::generate().public_key(),
18588 Keys::generate().public_key(),
18589 Keys::generate().public_key(),
18590 Keys::generate().public_key(),
18591 );
18592
18593 let yieldy = YieldyRelay(&bed.relay);
18594 let (wa, wb, wc, wd) = ([a], [b], [c], [d]);
18595 let (ra, rb, rc, rd) = tokio::join!(
18598 set_members_banned(&yieldy, community.id(), &wa, true),
18599 set_members_banned(&yieldy, community.id(), &wb, true),
18600 set_members_banned(&yieldy, community.id(), &wc, true),
18601 set_members_banned(&yieldy, community.id(), &wd, true),
18602 );
18603 ra.unwrap();
18604 rb.unwrap();
18605 rc.unwrap();
18606 rd.unwrap();
18607
18608 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18609 assert_eq!(fresh.root_epoch, Epoch(2), "leader rotation + ONE coalesced rotation, never four");
18610 let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
18611 assert_eq!(version, 2, "two editions total: the leader's and the coalesced drain's");
18612 let view = fetch_authority(&bed.relay, &fresh).await;
18613 for pk in [a, b, c, d] {
18614 assert!(view.banned.contains(&pk.to_hex()), "{} missing from the fold", pk.to_hex());
18615 }
18616 }
18617
18618 #[tokio::test]
18619 async fn a_stacked_ban_and_unban_coalesce_in_arrival_order() {
18620 let (bed, owner, _member) = TestBed::new();
18624 bed.swap_to(&owner);
18625 let community = create_community(&bed.relay, "Net", bed.relays.clone(), None).await.unwrap();
18626 let x = Keys::generate().public_key();
18627 let y = Keys::generate().public_key();
18628
18629 let yieldy = YieldyRelay(&bed.relay);
18630 let (wx, wy) = ([x], [y]);
18631 let (rx1, ry, rx2) = tokio::join!(
18632 set_members_banned(&yieldy, community.id(), &wx, true), set_members_banned(&yieldy, community.id(), &wy, true), set_members_banned(&yieldy, community.id(), &wx, false), );
18636 rx1.unwrap();
18637 ry.unwrap();
18638 rx2.unwrap();
18639
18640 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18641 assert_eq!(fresh.root_epoch, Epoch(2), "X's rotation + one coalesced rotation for Y");
18642 let view = fetch_authority(&bed.relay, &fresh).await;
18643 assert!(!view.banned.contains(&x.to_hex()), "the stacked unban netted X out");
18644 assert!(view.banned.contains(&y.to_hex()), "Y's stacked ban landed");
18645 }
18646
18647 #[tokio::test]
18648 async fn a_second_member_folds_every_stacked_ban() {
18649 let (bed, owner, member) = TestBed::new();
18654 bed.swap_to(&owner);
18655 let community = create_community(&bed.relay, "Agree", bed.relays.clone(), None).await.unwrap();
18656 let a = Keys::generate().public_key();
18657 let b = Keys::generate().public_key();
18658
18659 let yieldy = YieldyRelay(&bed.relay);
18660 let (wa, wb) = ([a], [b]);
18661 let (ra, rb) = tokio::join!(
18662 set_members_banned(&yieldy, community.id(), &wa, true),
18663 set_members_banned(&yieldy, community.id(), &wb, true),
18664 );
18665 ra.unwrap();
18666 rb.unwrap();
18667
18668 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18670 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
18671 let bundle_json = serde_json::to_string(&bundle).unwrap();
18672
18673 bed.swap_to(&member);
18674 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
18675 let _ = follow_control(&bed.relay, &joined).await;
18676 let view = fetch_authority(&bed.relay, &joined).await;
18677 assert!(
18678 view.banned.contains(&a.to_hex()) && view.banned.contains(&b.to_hex()),
18679 "an independent reader folds BOTH stacked bans: {:?}",
18680 view.banned
18681 );
18682 }
18683
18684 #[tokio::test]
18685 async fn a_net_zero_batch_publishes_no_edition() {
18686 let (bed, owner, _member) = TestBed::new();
18689 bed.swap_to(&owner);
18690 let community = create_community(&bed.relay, "Noop", bed.relays.clone(), None).await.unwrap();
18691 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18692 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
18693 let x = Keys::generate().public_key();
18694
18695 let decoy = Keys::generate().public_key();
18698 let yieldy = YieldyRelay(&bed.relay);
18699 let (wd, wx) = ([decoy], [x]);
18700 let (rd, r1, r2) = tokio::join!(
18701 set_members_banned(&yieldy, community.id(), &wd, true),
18702 set_members_banned(&yieldy, community.id(), &wx, true),
18703 set_members_banned(&yieldy, community.id(), &wx, false),
18704 );
18705 rd.unwrap();
18706 r1.unwrap();
18707 r2.unwrap();
18708
18709 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18710 assert_eq!(fresh.root_epoch, Epoch(1), "only the decoy's rotation — the net-zero drain rotated nothing");
18711 let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
18712 assert_eq!(version, 1, "only the decoy's edition — the net-zero drain published nothing");
18713 let view = fetch_authority(&bed.relay, &fresh).await;
18714 assert!(!view.banned.contains(&x.to_hex()));
18715 assert!(view.banned.contains(&decoy.to_hex()));
18716 }
18717
18718 struct BrokenPublish<'a>(&'a MemoryRelay);
18720 #[async_trait::async_trait]
18721 impl Transport for BrokenPublish<'_> {
18722 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
18723 tokio::task::yield_now().await;
18724 Err("relay refused".to_string())
18725 }
18726 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
18727 tokio::task::yield_now().await;
18728 self.0.fetch(query, relays).await
18729 }
18730 async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
18731 tokio::task::yield_now().await;
18732 self.0.fetch_plane(plane, query, relays).await
18733 }
18734 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
18735 tokio::task::yield_now().await;
18736 Err("relay refused".to_string())
18737 }
18738 }
18739
18740 #[tokio::test]
18741 async fn a_failed_publish_answers_every_stacked_caller_and_never_hangs() {
18742 let (bed, owner, _member) = TestBed::new();
18746 bed.swap_to(&owner);
18747 let community = create_community(&bed.relay, "Dead", bed.relays.clone(), None).await.unwrap();
18748 let a = Keys::generate().public_key();
18749 let b = Keys::generate().public_key();
18750
18751 let broken = BrokenPublish(&bed.relay);
18752 let (wa, wb) = ([a], [b]);
18753 let (ra, rb) = tokio::time::timeout(
18754 std::time::Duration::from_secs(30),
18755 async {
18756 tokio::join!(
18757 set_members_banned(&broken, community.id(), &wa, true),
18758 set_members_banned(&broken, community.id(), &wb, true),
18759 )
18760 },
18761 )
18762 .await
18763 .expect("stacked callers must resolve, never hang");
18764 assert!(ra.is_err(), "the leader surfaced the publish failure");
18765 assert!(rb.is_err(), "the stacked caller surfaced it too");
18766
18767 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18769 let view = fetch_authority(&bed.relay, &fresh).await;
18770 assert!(view.banned.is_empty(), "no half-published state: {:?}", view.banned);
18771 assert_eq!(fresh.root_epoch, Epoch(0), "no rotation on a failed edition");
18772 }
18773
18774 #[tokio::test]
18775 async fn a_poison_intent_fails_alone_not_its_batchmates() {
18776 let (bed, owner, _member) = TestBed::new();
18779 bed.swap_to(&owner);
18780 let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
18781 let filler: Vec<String> = (0..498).map(|i| format!("{i:064x}")).collect();
18784 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18785 set_banlist(&bed.relay, &held, &filler).await.unwrap();
18786 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18787 crate::db::community::set_community_banlist(&cid_hex, &filler, 1).unwrap();
18788
18789 let (a, x, y) = (Keys::generate().public_key(), Keys::generate().public_key(), Keys::generate().public_key());
18790 let yieldy = YieldyRelay(&bed.relay);
18791 let (wa, wx, wy) = ([a], [x], [y]);
18792 let (ra, rx, ry) = tokio::join!(
18793 set_members_banned(&yieldy, community.id(), &wa, true),
18794 set_members_banned(&yieldy, community.id(), &wx, true),
18795 set_members_banned(&yieldy, community.id(), &wy, true),
18796 );
18797 ra.unwrap();
18798 rx.unwrap();
18799 let err = ry.unwrap_err();
18800 assert!(err.contains("ceiling"), "the over-cap intent failed alone: {err}");
18801
18802 let list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
18803 assert_eq!(list.len(), 500, "the batch published up to the ceiling");
18804 assert!(list.contains(&x.to_hex()) && !list.contains(&y.to_hex()));
18805 }
18806
18807 #[tokio::test]
18808 async fn ban_many_is_one_edition_one_refound_for_the_whole_wave() {
18809 let (bed, owner, _member) = TestBed::new();
18810 bed.swap_to(&owner);
18811 let community = create_community(&bed.relay, "Purge", bed.relays.clone(), None).await.unwrap();
18812 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18813 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
18814 let wave: Vec<PublicKey> = (0..3).map(|_| Keys::generate().public_key()).collect();
18815
18816 set_members_banned(&bed.relay, community.id(), &wave, true).await.unwrap();
18817
18818 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18819 assert_eq!(fresh.root_epoch, Epoch(1), "one refound for the whole wave, not one per target");
18820 let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
18821 assert_eq!(version, 1, "one banlist edition carried the whole wave");
18822 let view = fetch_authority(&bed.relay, &fresh).await;
18823 for pk in &wave {
18824 assert!(view.banned.contains(&pk.to_hex()), "{} missing from the fold", pk.to_hex());
18825 }
18826
18827 set_members_banned(&bed.relay, community.id(), &wave[..2], false).await.unwrap();
18829 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18830 assert_eq!(fresh.root_epoch, Epoch(1), "an unban rotates nothing");
18831 let view = fetch_authority(&bed.relay, &fresh).await;
18832 assert_eq!(view.banned.len(), 1, "reductive: exactly the un-unbanned target remains");
18833 assert!(view.banned.contains(&wave[2].to_hex()));
18834 }
18835
18836 #[tokio::test]
18837 async fn a_batch_past_the_banlist_cap_refuses_before_publishing() {
18838 let (bed, owner, _member) = TestBed::new();
18839 bed.swap_to(&owner);
18840 let community = create_community(&bed.relay, "Cap", bed.relays.clone(), None).await.unwrap();
18841 let wave: Vec<PublicKey> = (0..=crate::community::v2::roles::MAX_BANLIST).map(|_| Keys::generate().public_key()).collect();
18842
18843 let err = set_members_banned(&bed.relay, community.id(), &wave, true).await.unwrap_err();
18844 assert!(err.contains("ceiling"), "refused at the cap: {err}");
18845 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18846 assert_eq!(fresh.root_epoch, Epoch(0), "nothing rotated");
18847 let view = fetch_authority(&bed.relay, &fresh).await;
18848 assert!(view.banned.is_empty(), "nothing published");
18849 }
18850
18851 #[tokio::test]
18852 async fn an_uncited_kick_from_an_admin_is_not_honored() {
18853 let (bed, owner, member) = TestBed::new();
18858 bed.swap_to(&owner);
18859 let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
18860 let admin = Keys::generate();
18861 let member_pk = member.keys.public_key();
18862 grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
18863
18864 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18865 let view = fetch_authority(&bed.relay, &community).await;
18866 crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
18867
18868 let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
18869 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
18870 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
18871 let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
18872
18873 let joined = guestbook::GuestbookEvent { epoch: None, observed_at: None,
18874 rumor_id: [1u8; 32],
18875 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
18876 };
18877 let kick = |citation, id: u8, at| guestbook::GuestbookEvent { epoch: None, observed_at: None,
18878 rumor_id: [id; 32],
18879 entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
18880 };
18881 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
18882 let empty_bans = std::collections::BTreeSet::new();
18883 let empty_marks = std::collections::BTreeMap::new();
18884 let fold = |evs: &[guestbook::GuestbookEvent]| {
18885 fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
18886 };
18887
18888 assert!(
18889 fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
18890 "an uncited kick from an admin is not honored"
18891 );
18892 assert!(
18893 !fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
18894 "the same kick WITH its synced citation removes them"
18895 );
18896 }
18897
18898 #[tokio::test]
18899 async fn kicking_an_admin_strips_their_roles_first() {
18900 let (bed, owner, member) = TestBed::new();
18904 bed.swap_to(&owner);
18905 let community = create_community(&bed.relay, "Compose", bed.relays.clone(), None).await.unwrap();
18906 let member_pk = member.keys.public_key();
18907 let member_hex = member_pk.to_hex();
18908 let owner_hex = owner.keys.public_key().to_hex();
18909
18910 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
18911 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
18912
18913 kick_member(&bed.relay, &community, &member_pk).await.unwrap();
18914
18915 let view = fetch_authority(&bed.relay, &community).await;
18916 assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
18917 assert!(
18918 !view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
18919 "a kicked admin holds no bit"
18920 );
18921 assert!(
18922 !memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
18923 "and the directive still removed them"
18924 );
18925 }
18926
18927 #[tokio::test]
18928 async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
18929 let (bed, owner, member) = TestBed::new();
18930 bed.swap_to(&owner);
18931 let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
18932 let member_pk = member.keys.public_key();
18933 let member_hex = member_pk.to_hex();
18934 let owner_hex = owner.keys.public_key().to_hex();
18935
18936 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
18937 let view = fetch_authority(&bed.relay, &community).await;
18938 assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
18939 assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
18940
18941 let second = Keys::generate().public_key();
18944 grant_admin(&bed.relay, &community, &second).await.unwrap();
18945 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
18946 let view = fetch_authority(&bed.relay, &community).await;
18947 assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
18948 assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
18949 let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
18950 assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
18951
18952 revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
18954 let view = fetch_authority(&bed.relay, &community).await;
18955 assert!(!view.roles.is_admin(&member_hex), "revoked");
18956 assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
18957 assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
18958 }
18959
18960 #[tokio::test]
18961 async fn follow_control_persists_the_roster_for_sync_local_reads() {
18962 let (bed, owner, member) = TestBed::new();
18963 bed.swap_to(&owner);
18964 let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
18965 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18966 let member_hex = member.keys.public_key().to_hex();
18967 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
18968
18969 follow_control(&bed.relay, &community).await.unwrap().updated;
18971 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
18972 assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
18973
18974 let withholding = MemoryRelay::new();
18977 let _ = follow_control(&withholding, &community).await;
18978 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
18979 assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
18980
18981 revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
18983 follow_control(&bed.relay, &community).await.unwrap().updated;
18984 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
18985 assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
18986 }
18987
18988 #[tokio::test]
18989 async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
18990 let (bed, owner, member) = TestBed::new();
18991 bed.swap_to(&owner);
18992 let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
18993
18994 bed.swap_to(&member);
18995 let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
18996 assert!(err.contains("owner"), "refused before any publish: {err}");
18997
18998 bed.swap_to(&owner);
19001 let view = fetch_authority(&bed.relay, &community).await;
19002 assert!(view.roles.roles.is_empty(), "no role edition landed");
19003 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
19004 let view = fetch_authority(&bed.relay, &community).await;
19005 assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
19006 }
19007
19008 #[tokio::test]
19009 async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
19010 let (bed, owner, member) = TestBed::new();
19011 bed.swap_to(&owner);
19012 let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
19013 let member_pk = member.keys.public_key();
19014
19015 let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
19018 set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
19019 grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
19020
19021 let withholding = MemoryRelay::new();
19024 let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
19025 assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
19026
19027 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
19029 let view = fetch_authority(&bed.relay, &community).await;
19030 let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
19031 assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
19032 assert!(grant.role_ids.contains(&mod_rid));
19033 }
19034
19035 #[tokio::test]
19036 async fn fetch_authority_reflects_a_granted_admin() {
19037 let (bed, owner, member) = TestBed::new();
19038 bed.swap_to(&owner);
19039 let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
19040 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
19041 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
19042 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
19043
19044 let view = fetch_authority(&bed.relay, &community).await;
19045 let member_hex = member.keys.public_key().to_hex();
19046 assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
19047 assert!(
19048 view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
19049 "an ADMIN_ALL grant carries KICK"
19050 );
19051 assert!(view.banned.is_empty());
19052 }
19053
19054 #[tokio::test]
19059 async fn a_pin_edition_folds_persists_and_reads_back() {
19060 let (_tmp, _guard, _owner) = init_test_db();
19061 let relay = MemoryRelay::new();
19062 let community = create_community(&relay, "Pinsville", vec!["wss://r".into()], None).await.unwrap();
19063 let general = community.channels[0].id;
19064 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19065
19066 send_message(&relay, &community, &general, "pin-worthy").await.unwrap();
19067 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19068 let opened = page
19069 .iter()
19070 .find_map(|f| match &f.event {
19071 ChatEvent::Message { opened, .. } => Some(opened.clone()),
19072 _ => None,
19073 })
19074 .expect("the sent message reads back");
19075
19076 let ch = community.channels[0].clone();
19077 let conv = channel_conv_key_at(&community, &ch, 0).expect("owner holds the public plane key");
19078 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19079 let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
19080
19081 let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
19082 publish_control_edition(&relay, &community, vsk::PINS, &eid, &content).await.unwrap();
19083 follow_control(&relay, &community).await.unwrap().updated;
19084
19085 let read = read_channel_pins(&community, &general).unwrap();
19086 assert!(!read.sealed);
19087 assert!(read.version >= 1, "the folded head persisted");
19088 assert_eq!(read.pins.len(), 1);
19089 assert_eq!(read.pins[0].content, "pin-worthy");
19090 assert_eq!(read.pins[0].rumor_id, opened.rumor_id.to_hex());
19091 }
19092
19093 #[tokio::test]
19094 async fn a_fresh_join_sees_pins_and_the_banlist_without_waiting_for_a_follow() {
19095 let (bed, owner, member) = TestBed::new();
19100 bed.swap_to(&owner);
19101 let community = create_community(&bed.relay, "PinJoin", bed.relays.clone(), None).await.unwrap();
19102 let general = community.channels[0].id;
19103 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19104
19105 send_message(&bed.relay, &community, &general, "pin-worthy").await.unwrap();
19106 let page = fetch_channel(&bed.relay, &community, &general, 10).await.unwrap();
19107 let opened = page
19108 .iter()
19109 .find_map(|f| match &f.event {
19110 ChatEvent::Message { opened, .. } => Some(opened.clone()),
19111 _ => None,
19112 })
19113 .expect("the sent message reads back");
19114 let ch = community.channels[0].clone();
19115 let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
19116 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19117 let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
19118 let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
19119 publish_control_edition(&bed.relay, &community, vsk::PINS, &eid, &content).await.unwrap();
19120 let spammer = Keys::generate().public_key().to_hex();
19121 set_banlist(&bed.relay, &community, std::slice::from_ref(&spammer)).await.unwrap();
19122 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
19123 let bundle_json = serde_json::to_string(&bundle).unwrap();
19124
19125 bed.swap_to(&member);
19126 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
19127 let read = read_channel_pins(&joined, &general).unwrap();
19129 assert!(!read.sealed);
19130 assert_eq!(read.pins.len(), 1, "pins visible the instant the join returns");
19131 assert_eq!(read.pins[0].content, "pin-worthy");
19132 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
19133 let banlist = crate::db::community::get_community_banlist(&cid_hex).unwrap();
19134 assert!(banlist.contains(&spammer), "the banlist is enforced from the join, not the first follow");
19135 }
19136
19137 #[tokio::test]
19140 async fn an_unauthorized_pin_edition_never_folds() {
19141 let (_tmp, _guard, _owner) = init_test_db();
19142 let relay = MemoryRelay::new();
19143 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
19144 let general = community.channels[0].id;
19145
19146 let rogue = Keys::generate();
19147 let group = control::ControlPlane::of(&community).write_group().unwrap();
19148 let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
19149 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::PINS, &eid, 1, None, r#"{"entries":[]}"#, 2_000, None);
19150 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(2_000)).unwrap();
19151 relay.publish(&wrap, &community.relays).await.unwrap();
19152
19153 follow_control(&relay, &community).await.unwrap().updated;
19154 let read = read_channel_pins(&community, &general).unwrap();
19155 assert_eq!(read.version, 0, "an unauthorized edition never persists a head");
19156 assert!(read.pins.is_empty());
19157 }
19158
19159 #[tokio::test]
19162 async fn owner_silently_widens_a_legacy_admin_role() {
19163 let (_tmp, _guard, owner) = init_test_db();
19164 let relay = MemoryRelay::new();
19165 let community = create_community(&relay, "Legacy", vec!["wss://r".into()], None).await.unwrap();
19166 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19167 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5d; 32]);
19168
19169 let legacy = Role {
19171 role_id: rid.clone(),
19172 name: "Admin".into(),
19173 position: 1,
19174 permissions: Permissions(Permissions::ADMIN_FOUNDING_MASK),
19175 scope: RoleScope::Server,
19176 color: 0,
19177 };
19178 publish_role(&relay, &community, &owner, &legacy, 1).await;
19179 follow_control(&relay, &community).await.unwrap().updated;
19180
19181 assert!(upgrade_admin_role_pin_bit(&relay, &community).await.unwrap(), "the widening publishes");
19182 follow_control(&relay, &community).await.unwrap().updated;
19183 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
19184 let widened = roles.roles.iter().find(|r| r.role_id == rid).expect("same entity");
19185 assert!(widened.permissions.contains(Permissions::PIN_MESSAGES), "bit 11 landed");
19186 assert!(widened.permissions.contains(Permissions::ADMIN_FOUNDING_MASK), "nothing stripped");
19187
19188 assert!(!upgrade_admin_role_pin_bit(&relay, &community).await.unwrap());
19190 }
19191
19192 #[tokio::test]
19195 async fn the_deletion_duty_omits_a_pinned_message() {
19196 let (_tmp, _guard, _owner) = init_test_db();
19197 let relay = MemoryRelay::new();
19198 let community = create_community(&relay, "Duties", vec!["wss://r".into()], None).await.unwrap();
19199 let general = community.channels[0].id;
19200 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19201
19202 let rumor_id = send_message(&relay, &community, &general, "soon deleted").await.unwrap();
19203 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19204 let opened = page
19205 .iter()
19206 .find_map(|f| match &f.event {
19207 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
19208 _ => None,
19209 })
19210 .unwrap();
19211 let ch = community.channels[0].clone();
19212 let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
19213 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19214 publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
19215 assert_eq!(read_channel_pins(&community, &general).unwrap().pins.len(), 1);
19216
19217 run_pin_duty(&relay, &ch_hex, &rumor_id, None).await.unwrap();
19219 let after = read_channel_pins(&community, &general).unwrap();
19220 assert!(after.pins.is_empty(), "the omitting edition landed");
19221 assert!(after.version >= 2, "a NEW edition, not a local erase");
19222 }
19223
19224 #[tokio::test]
19227 async fn the_edit_duty_refreshes_a_pinned_proof() {
19228 let (_tmp, _guard, _owner) = init_test_db();
19229 let relay = MemoryRelay::new();
19230 let community = create_community(&relay, "Edits", vec!["wss://r".into()], None).await.unwrap();
19231 let general = community.channels[0].id;
19232 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19233
19234 let rumor_id = send_message(&relay, &community, &general, "first words").await.unwrap();
19235 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19236 let opened = page
19237 .iter()
19238 .find_map(|f| match &f.event {
19239 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
19240 _ => None,
19241 })
19242 .unwrap();
19243 let ch = community.channels[0].clone();
19244 let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
19245 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19246 publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
19247
19248 send_edit(&relay, &community, &general, &rumor_id, "second thoughts").await.unwrap();
19249 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19250 let edit_opened = page
19251 .iter()
19252 .find_map(|f| match &f.event {
19253 ChatEvent::Edit { opened, .. } => Some(opened.clone()),
19254 _ => None,
19255 })
19256 .expect("the edit reads back");
19257
19258 run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened.clone())).await.unwrap();
19259 let after = read_channel_pins(&community, &general).unwrap();
19260 assert_eq!(after.pins.len(), 1);
19261 assert_eq!(
19262 after.pins[0].content, "second thoughts",
19263 "the refreshed bundle proves the revision"
19264 );
19265 let v = after.version;
19266
19267 run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened)).await.unwrap();
19269 assert_eq!(read_channel_pins(&community, &general).unwrap().version, v, "idempotent");
19270 }
19271
19272 #[tokio::test]
19277 async fn an_own_edit_echo_carries_the_pin_duty_trigger() {
19278 let (_tmp, _guard, _owner) = init_test_db();
19279 let relay = MemoryRelay::new();
19280 let community = create_community(&relay, "OwnEdit", vec!["wss://r".into()], None).await.unwrap();
19281 let general = community.channels[0].id;
19282
19283 let rumor_id = send_message(&relay, &community, &general, "before").await.unwrap();
19284 send_edit(&relay, &community, &general, &rumor_id, "after").await.unwrap();
19285 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19286 let edit_event = &page.iter().find(|f| matches!(&f.event, ChatEvent::Edit { .. })).expect("the edit reads back").event;
19287 let msg_event = &page.iter().find(|f| matches!(&f.event, ChatEvent::Message { .. })).expect("the message reads back").event;
19288
19289 let updated = crate::community::v2::inbound::ChatPersist::Updated {
19290 message: crate::types::Message::default(),
19291 edit_event: None,
19292 };
19293 let (target_hex, opened) = own_echo_pin_duty(&updated, edit_event).expect("an applied own edit fires the duty");
19294 assert_eq!(target_hex, rumor_id, "the duty targets the EDITED message, not the edit rumor");
19295 assert_ne!(opened.rumor_id.to_hex(), rumor_id, "and carries the edit's own opened stream for the bundle");
19296
19297 let new = crate::community::v2::inbound::ChatPersist::New(crate::types::Message::default());
19299 assert!(own_echo_pin_duty(&new, msg_event).is_none());
19300 assert!(own_echo_pin_duty(&updated, msg_event).is_none());
19302 }
19303
19304 #[tokio::test]
19308 async fn a_rotation_reseals_the_pin_list_under_the_new_epoch() {
19309 let (_tmp, _guard, _owner) = init_test_db();
19310 let relay = MemoryRelay::new();
19311 let community = create_community(&relay, "Reseal", vec!["wss://r".into()], None).await.unwrap();
19312 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19313 let chan = create_private_channel(&relay, &community, "vault").await.unwrap();
19314 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19315 let ch_hex = crate::simd::hex::bytes_to_hex_32(&chan.0);
19316
19317 let rumor_id = send_message(&relay, &community, &chan, "sealed wisdom").await.unwrap();
19318 let page = fetch_channel(&relay, &community, &chan, 10).await.unwrap();
19319 let opened = page
19320 .iter()
19321 .find_map(|f| match &f.event {
19322 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
19323 _ => None,
19324 })
19325 .unwrap();
19326 let ch = community.channel(&chan).unwrap().clone();
19327 let old_epoch = ch.epoch.0;
19328 let conv = channel_conv_key_at(&community, &ch, old_epoch).unwrap();
19329 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19330 publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
19331 let (_, v_before) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
19332
19333 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
19335 rekey_channel_excluding(&relay, &community, &chan, &roster, &[], &[Keys::generate().public_key()])
19336 .await
19337 .unwrap();
19338
19339 let (content, version) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
19341 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
19342 assert_eq!(
19343 parsed["epoch"].as_str().unwrap(),
19344 (old_epoch + 1).to_string(),
19345 "the reseal names the rotated epoch"
19346 );
19347 assert!(version > v_before, "a real edition, not a local rewrite");
19348
19349 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19351 let read = read_channel_pins(&community, &chan).unwrap();
19352 assert!(!read.sealed);
19353 assert_eq!(read.pins.len(), 1);
19354 assert_eq!(read.pins[0].content, "sealed wisdom");
19355 }
19356
19357 #[tokio::test]
19363 async fn a_published_banlist_echoes_locally_before_any_fold() {
19364 let (_tmp, _guard, _owner) = init_test_db();
19365 let relay = MemoryRelay::new();
19366 let community = create_community(&relay, "Modtown", vec!["wss://r".into()], None).await.unwrap();
19367 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19368 let spammer_a = "aa".repeat(32);
19369 let spammer_b = "bb".repeat(32);
19370
19371 set_banlist(&relay, &community, &[spammer_a.clone()]).await.unwrap();
19373 assert_eq!(
19374 crate::db::community::get_community_banlist(&cid_hex).unwrap(),
19375 vec![spammer_a.clone()],
19376 "the publish echoes without waiting for a fold"
19377 );
19378
19379 let mut list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
19381 list.push(spammer_b.clone());
19382 set_banlist(&relay, &community, &list).await.unwrap();
19383 let held = crate::db::community::get_community_banlist(&cid_hex).unwrap();
19384 assert!(
19385 held.contains(&spammer_a) && held.contains(&spammer_b),
19386 "sequential bans UNION; the second must not erase the first: {held:?}"
19387 );
19388
19389 follow_control(&relay, &community).await.unwrap().updated;
19391 let folded = crate::db::community::get_community_banlist(&cid_hex).unwrap();
19392 assert!(folded.contains(&spammer_a) && folded.contains(&spammer_b));
19393 }
19394}