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 {
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 extra: Default::default(),
1031 });
1032 }
1033 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1034 transport.publish(&event, &community.relays).await?;
1035 let signers = live_signers_for(&list, &cid_hex, now_ms());
1036 publish_invite_registry(transport, community, &session, &signers).await
1037 })
1038 .await
1039}
1040
1041pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
1047 crate::db::scoped(async move {
1048 let session = crate::db::current_session();
1049 let signer = crate::signer::active_signer()?;
1050 let my_pk = me_pk()?;
1051 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1052 let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
1053 let entry = list
1054 .entries
1055 .iter()
1056 .find(|e| e.token == token_hex && e.community_id == cid_hex)
1057 .cloned()
1058 .ok_or("no such link in the invite list")?;
1059 let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
1061 let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
1062 transport.publish_durable(&revocation, &community.relays).await?;
1063 list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
1065 list.entries.retain(|e| e.token != token_hex);
1066 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1067 transport.publish(&event, &community.relays).await?;
1068 let signers = live_signers_for(&list, &cid_hex, now_ms());
1069 let others_vend = if signers.is_empty() {
1077 match fetch_control_plane_whole(transport, community).await {
1078 Some(editions) => {
1079 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1080 .unwrap_or_default()
1081 .into_iter()
1082 .filter(|(_, f)| f.0 == community.root_epoch.0)
1083 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1084 .collect();
1085 let owner_hex = community.owner().map(|o| o.to_hex()).unwrap_or_default();
1086 let authority = fold_authority(community, &editions, &floors);
1087 live_invite_link_sets(community.id(), &owner_hex, &editions, &authority, &floors)
1088 .iter()
1089 .any(|s| s.creator_hex != my_pk.to_hex())
1090 }
1091 None => true,
1096 }
1097 } else {
1098 false
1099 };
1100 publish_invite_registry(transport, community, &session, &signers).await?;
1101 let _ = crate::db::community::delete_public_invite(token_hex);
1102 if signers.is_empty() && !others_vend {
1116 let mut rotated = false;
1117 for attempt in 0..3u8 {
1118 match refound_community(transport, community, &[]).await {
1119 Ok(_) => {
1120 rotated = true;
1121 break;
1122 }
1123 Err(e) if attempt == 2 => {
1124 crate::log_warn!(
1125 "[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",
1126 &cid_hex[..8.min(cid_hex.len())]
1127 );
1128 crate::emit_event(
1129 "community_privatize_rotation_failed",
1130 &serde_json::json!({ "community_id": cid_hex, "error": e }),
1131 );
1132 }
1133 Err(_) => continue,
1134 }
1135 }
1136 if rotated {
1137 crate::log_info!("[v2] {} privatized: base key rotated so link-joined readers are cut off", &cid_hex[..8.min(cid_hex.len())]);
1138 }
1139 }
1140 Ok(())
1141 })
1142 .await
1143}
1144
1145pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1150 crate::db::scoped(async move {
1151 let session = crate::db::current_session();
1152 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1153 let signer = crate::signer::active_signer()?;
1158 let my_pk = me_pk()?;
1159 let query = Query {
1160 kinds: vec![super::kind::INVITE_LIST],
1161 authors: vec![my_pk.to_hex()],
1162 limit: Some(4),
1163 ..Default::default()
1164 };
1165 let events = transport.fetch(&query, &community.relays).await?;
1166 let mut best: Option<(u64, invite::InviteList)> = None;
1167 for e in events {
1168 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
1169 let at = e.created_at.as_secs();
1170 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
1171 best = Some((at, l));
1172 }
1173 }
1174 }
1175 let Some((_, list)) = best else {
1176 return Ok(());
1177 };
1178 let creator = my_pk;
1179 let now = now_ms();
1180 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1181 for entry in &list.entries {
1182 if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
1183 continue;
1184 }
1185 if entry.expires_at.is_some_and(|exp| now > exp) {
1188 continue;
1189 }
1190 let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
1191 let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
1192 let bundle = bundle_of(community, BundleAudience::Link, Some(creator), entry.expires_at, entry.label.clone());
1193 let bundle_key = super::derive::invite_bundle_key(&token);
1194 if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
1195 let _ = transport.publish_durable(&event, &community.relays).await;
1196 }
1197 }
1198 let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
1208 if !mine_here {
1209 return Ok(());
1210 }
1211 let signers = live_signers_for(&list, &cid_hex, now);
1212 let _ = publish_invite_registry(transport, community, &session, &signers).await;
1213 Ok(())
1214 })
1215 .await
1216}
1217
1218pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1223 let Ok(owner) = community.owner() else { return false };
1224 let Some(editions) = fetch_control_plane_whole(transport, community).await else { return true };
1228 let cid = community.id();
1229 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1230 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1231 .unwrap_or_default()
1232 .into_iter()
1233 .filter(|(_, f)| f.0 == community.root_epoch.0)
1234 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1235 .collect();
1236 let authority = fold_authority(community, &editions, &floors);
1237 !live_invite_link_sets(cid, &owner.to_hex(), &editions, &authority, &floors).is_empty()
1238}
1239
1240async fn fetch_control_plane_whole<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Option<Vec<ParsedEdition>> {
1246 let control = control::ControlPlane::of(community);
1247 let mut editions: Vec<ParsedEdition> = Vec::new();
1248 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1249 let mut oldest: Option<u64> = None;
1250 let mut until: Option<u64> = None;
1251 for page in 0..COMPACT_MAX_PAGES {
1252 let query = Query {
1256 kinds: vec![stream::KIND_WRAP],
1257 authors: vec![control.pk_hex()],
1258 until,
1259 limit: Some(FOLLOW_PAGE),
1260 evidence: crate::community::transport::Evidence::Quorum,
1261 ..Default::default()
1262 };
1263 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return None };
1264 let mut fresh = 0usize;
1265 for w in &wraps {
1266 if !seen_wraps.insert(w.id) {
1267 continue;
1268 }
1269 fresh += 1;
1270 let at = w.created_at.as_secs();
1271 if oldest.is_none_or(|o| at < o) {
1272 oldest = Some(at);
1273 }
1274 if let Ok((ed, _)) = control.open(w) {
1275 editions.push(ed);
1276 }
1277 }
1278 if fresh == 0 {
1279 if wraps.len() >= FOLLOW_PAGE {
1280 return None; }
1282 return Some(editions);
1283 }
1284 until = oldest;
1285 if page + 1 == COMPACT_MAX_PAGES {
1286 return None;
1287 }
1288 }
1289 Some(editions)
1290}
1291
1292fn live_invite_link_sets(
1297 cid: &crate::community::CommunityId,
1298 owner_hex: &str,
1299 editions: &[ParsedEdition],
1300 authority: &AuthoritySet,
1301 floors: &Floors,
1302) -> Vec<crate::db::community::InviteLinkSetRow> {
1303 use crate::community::roles::Permissions;
1304 use std::collections::BTreeMap;
1305 let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
1306 for e in editions {
1307 if e.vsk == vsk::INVITE_LINKS {
1308 by_eid.entry(e.entity_id).or_default().push(e);
1309 }
1310 }
1311 let mut sets: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1312 for (eid, group) in &by_eid {
1313 let authed: Vec<&ParsedEdition> = group
1319 .iter()
1320 .copied()
1321 .filter(|p| {
1322 let author = p.author.to_hex();
1323 !authority.banned.contains(&author)
1325 && authority.roles.is_authorized(&author, Some(owner_hex), Permissions::CREATE_INVITE)
1326 && super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
1327 })
1328 .collect();
1329 if authed.is_empty() {
1330 continue;
1331 }
1332 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
1333 let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
1334 if let Ok(signers) = invite::parse_registry_content(&authed[hi].content) {
1335 if signers.is_empty() {
1336 continue; }
1338 sets.push(crate::db::community::InviteLinkSetRow {
1339 creator_hex: authed[hi].author.to_hex(),
1340 locators: signers.iter().map(|p| p.to_hex()).collect(),
1341 });
1342 }
1343 }
1344 sets
1345}
1346
1347fn flatten_link_sets(sets: &[crate::db::community::InviteLinkSetRow]) -> Vec<String> {
1349 let mut flat: Vec<String> = sets.iter().flat_map(|s| s.locators.iter().cloned()).collect();
1350 flat.sort();
1351 flat.dedup();
1352 flat
1353}
1354
1355async fn accept_bundle<T: Transport + ?Sized>(
1361 transport: &T,
1362 bundle: &CommunityInvite,
1363 invited_by: Option<PublicKey>,
1364 announce_join: bool,
1365) -> Result<CommunityV2, String> {
1366 crate::db::scoped(async move {
1367 let signer = crate::signer::active_signer()?;
1368 let my_pk = me_pk()?;
1369 let at_ms = now_ms();
1370 if bundle.expired(at_ms) {
1372 return Err("this invite has expired".to_string());
1373 }
1374 let community = CommunityV2::from_bundle(bundle, at_ms)?;
1376 let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
1379
1380 let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
1389 v.session.is_live()
1390 && v.at.elapsed() < VERIFIED_PREVIEW_TTL
1391 && v.community_id == community.id().0
1392 && v.community_root == community.community_root
1393 });
1394 let (community, join_heads, join_banlist, join_pins, join_banlist_content) = match handoff {
1395 Some(v) => {
1396 let mut c = v.folded;
1397 c.created_at_ms = at_ms;
1399 (c, v.heads, v.banned, v.pins, v.banlist_persist)
1400 }
1401 None => {
1402 let vj = verify_owner_root_and_reconcile(transport, community).await?;
1403 (vj.community, vj.heads, vj.banned, vj.pins, vj.banlist_persist)
1404 }
1405 };
1406
1407 if is_dissolved(transport, &community).await {
1409 return Err(ERR_DISSOLVED.to_string());
1410 }
1411
1412 if join_banlist.contains(&my_pk.to_hex()) {
1417 return Err("you are banned from this community".to_string());
1418 }
1419
1420 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1428 for h in &join_heads {
1429 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)?;
1430 }
1431 crate::db::community::save_community_v2(&community)?;
1432 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
1435 for ch in &community.channels {
1438 if let (true, Some(key)) = (ch.private, ch.key) {
1439 let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
1440 }
1441 }
1442 if let Some((banned_list, version)) = &join_banlist_content {
1448 let _ = crate::db::community::set_community_banlist(&cid_hex, banned_list, *version as i64);
1449 }
1450 for (channel_hex, content, version, _author, _at) in &join_pins {
1451 if matches!(
1452 crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64),
1453 Ok(true)
1454 ) {
1455 crate::emit_event(
1456 "community_pins_updated",
1457 &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
1458 );
1459 }
1460 }
1461
1462 if announce_join && !already_held {
1468 let attribution = invited_by
1469 .map(|p| p.to_hex())
1470 .or_else(|| bundle.creator_npub.clone())
1471 .zip(Some(bundle.label.clone().unwrap_or_default()));
1472 let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
1473 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1474 let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
1475 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1476 let _ = transport.publish(&join_wrap, &community.relays).await;
1477 }
1478 }
1479
1480 if announce_join {
1490 match republish_community_list(transport, Some(community.id())).await {
1491 Ok(true) => {}
1492 Ok(false) => republish_community_list_durable(Some(*community.id())),
1493 Err(e) => {
1494 crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
1495 republish_community_list_durable(Some(*community.id()));
1496 }
1497 }
1498 }
1499 Ok(community)
1500 })
1501 .await
1502}
1503
1504struct VerifiedJoin {
1526 community: CommunityV2,
1527 heads: Vec<FoldedHead>,
1528 banned: std::collections::BTreeSet<String>,
1530 pins: Vec<(String, String, u64, String, u64)>,
1533 banlist_persist: Option<(Vec<String>, u64)>,
1535}
1536
1537async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
1538 transport: &T,
1539 community: CommunityV2,
1540) -> Result<VerifiedJoin, String> {
1541 let owner = community.owner()?;
1542 let control = control::ControlPlane::of(&community);
1543 let control_pk = control.pk_hex();
1544
1545 super::streamauth::prime(&community);
1552
1553 const PAGE: usize = 500;
1574 const MAX_PAGES: usize = 4;
1575 const FAR_FUTURE_SECS: u64 = 4_102_444_800; let mut editions: Vec<ParsedEdition> = Vec::new();
1577 let mut all_editions: Vec<ParsedEdition> = Vec::new();
1578 let mut found_genesis = false;
1579 let mut compacted_metadata = false;
1587 crate::log_debug!(
1588 "[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
1589 &control_pk[..12], community.root_epoch, community.relays
1590 );
1591 let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
1592 found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
1593 };
1594 for attempt in 0..2 {
1595 editions.clear();
1596 all_editions.clear();
1597 compacted_metadata = false;
1598 let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1599 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1600 for page_no in 0..MAX_PAGES {
1601 let query = Query {
1602 kinds: vec![stream::KIND_WRAP],
1603 authors: vec![control_pk.clone()],
1604 until,
1605 limit: Some(PAGE),
1606 evidence: crate::community::transport::Evidence::Full,
1607 ..Default::default()
1608 };
1609 let wraps = transport.fetch(&query, &community.relays).await?;
1610 crate::log_trace!(
1611 "[JoinVerify] attempt {} page {}: fetched {} wraps",
1612 attempt, page_no, wraps.len()
1613 );
1614 let mut oldest = u64::MAX;
1618 let mut fresh = 0usize;
1619 for w in &wraps {
1620 if !seen_wraps.insert(w.id) {
1621 continue;
1622 }
1623 fresh += 1;
1624 oldest = oldest.min(w.created_at.as_secs());
1625 if let Ok((ed, _)) = control.open(w) {
1626 crate::log_trace!(
1627 "[JoinVerify] edition vsk={} eid={} owner={} at={}",
1628 ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
1629 ed.author == owner, w.created_at.as_secs()
1630 );
1631 if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1632 if ed.author == owner {
1633 found_genesis = true;
1634 } else {
1635 compacted_metadata = true;
1636 }
1637 }
1638 if ed.author == owner {
1639 editions.push(ed.clone());
1640 }
1641 all_editions.push(ed);
1644 }
1645 }
1646 crate::log_debug!(
1647 "[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
1648 attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
1649 );
1650 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
1651 break; }
1653 until = Some(oldest);
1654 }
1655 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1656 break;
1657 }
1658 if attempt == 0 {
1659 if let Some(client) = crate::state::nostr_client() {
1666 super::streamauth::prime_auth(&client, &community.relays).await;
1667 }
1668 }
1669 }
1670 if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1671 return Err(
1672 "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"
1673 .to_string(),
1674 );
1675 }
1676 let empty_floors = Floors::new();
1685 let authority = AuthoritySet::owner_only();
1686 let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1687 let authority_full = fold_authority(&community, &all_editions, &empty_floors);
1692 let pins = apply_control_fold(&community, &all_editions, &empty_floors, &authority_full).pins_persist;
1697 Ok(VerifiedJoin {
1698 community: fold.updated.unwrap_or(community),
1699 heads: fold.heads,
1700 banned: authority_full.banned,
1701 pins,
1702 banlist_persist: authority_full.banlist_persist,
1703 })
1704}
1705
1706pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1710 let signer = crate::signer::active_signer()?;
1711 let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
1712 accept_bundle(transport, &bundle, Some(inviter), true).await
1713}
1714
1715pub async fn accept_parked_invite<T: Transport + ?Sized>(
1721 transport: &T,
1722 bundle_json: &str,
1723 inviter_hex: Option<&str>,
1724) -> Result<CommunityV2, String> {
1725 let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1726 let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1727 accept_bundle(transport, &bundle, invited_by, true).await
1728}
1729
1730pub async fn accept_migration_material<T: Transport + ?Sized>(
1736 transport: &T,
1737 jm: &super::list::JoinMaterial,
1738) -> Result<CommunityV2, String> {
1739 let bundle = material_to_invite(jm);
1740 accept_bundle(transport, &bundle, None, true).await
1741}
1742
1743pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1750 let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1751 let query = Query {
1756 kinds: vec![super::kind::INVITE_BUNDLE],
1757 authors: vec![parsed.link_signer.to_hex()],
1758 ..Default::default()
1759 };
1760 let relays = if parsed.bootstrap_relays.is_empty() {
1761 invite::stock_relays()
1762 } else {
1763 parsed.bootstrap_relays.clone()
1764 };
1765 let events = match transport.fetch(&query, &relays).await {
1770 Ok(evs) => evs,
1771 Err(_) => {
1772 wait_for_bootstrap_relay(&relays).await;
1773 transport.fetch(&query, &relays).await?
1774 }
1775 };
1776 let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1777
1778 let mut newest_live: Option<(u64, CommunityInvite)> = None;
1780 for event in &events {
1781 match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1782 Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1783 Ok(invite::BundleState::Live(bundle)) => {
1784 let at = event.created_at.as_secs();
1785 if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1786 newest_live = Some((at, *bundle));
1787 }
1788 }
1789 Err(_) => {} }
1791 }
1792 newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1793}
1794
1795async fn wait_for_bootstrap_relay(relays: &[String]) {
1799 let Some(client) = crate::state::nostr_client() else { return };
1800 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8);
1801 loop {
1802 for url in relays {
1803 if let Ok(Some(relay)) = client.relay(url).await {
1804 if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
1805 return;
1806 }
1807 }
1808 }
1809 if tokio::time::Instant::now() >= deadline {
1810 return;
1811 }
1812 tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1813 }
1814}
1815
1816struct VerifiedPreview {
1822 session: std::sync::Arc<crate::db::Session>,
1823 at: std::time::Instant,
1824 community_id: [u8; 32],
1825 community_root: [u8; 32],
1826 folded: CommunityV2,
1827 heads: Vec<FoldedHead>,
1828 banned: std::collections::BTreeSet<String>,
1831 pins: Vec<(String, String, u64, String, u64)>,
1834 banlist_persist: Option<(Vec<String>, u64)>,
1835}
1836static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
1837const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
1838
1839pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1846 let bundle = fetch_public_bundle(transport, url).await?;
1847 preview_bundle(transport, &bundle).await
1848}
1849
1850pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
1855 let community = CommunityV2::from_bundle(bundle, 0)?;
1856 match verify_owner_root_and_reconcile(transport, community.clone()).await {
1857 Ok(vj) => {
1858 let folded = vj.community;
1859 *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
1860 session: crate::db::current_session(),
1861 at: std::time::Instant::now(),
1862 community_id: folded.id().0,
1863 community_root: folded.community_root,
1864 folded: folded.clone(),
1865 heads: vj.heads,
1866 banned: vj.banned,
1867 pins: vj.pins,
1868 banlist_persist: vj.banlist_persist,
1869 });
1870 Ok(folded)
1871 }
1872 Err(_) => Ok(community),
1873 }
1874}
1875
1876pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1878 crate::db::scoped(async move {
1879 let bundle = fetch_public_bundle(transport, url).await?;
1881 accept_bundle(transport, &bundle, None, true).await
1882 })
1883 .await
1884}
1885
1886pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1888 crate::db::scoped(async move {
1889 let signer = crate::signer::active_signer()?;
1890 let my_pk = me_pk()?;
1891 let at_ms = now_ms();
1892 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1893 let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
1894 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1895 let _ = transport.publish(&wrap, &community.relays).await;
1896 }
1897 if let Err(e) = tombstone_community_list(transport, community.id(), &community.relays, at_ms).await {
1901 crate::log_net_fail!("[CommunityList] leave tombstone failed ({}) — retrying in the background", e);
1902 tombstone_community_list_durable(*community.id(), community.relays.clone(), at_ms);
1903 }
1904 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1905 Ok(())
1906 })
1907 .await
1908}
1909
1910pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1915 crate::db::scoped(async move {
1916 assert_current_root(community)?;
1917 let signer = crate::signer::active_signer()?;
1918 let my_pk = me_pk()?;
1919 let authority = fetch_authority(transport, community).await;
1921 let owner_hex = community.owner()?.to_hex();
1922 if !authority.roles.can_act_on_member(
1923 &my_pk.to_hex(),
1924 Some(&owner_hex),
1925 &target.to_hex(),
1926 crate::community::roles::Permissions::KICK,
1927 ) {
1928 return Err("not authorized to kick this member".to_string());
1929 }
1930 let target_hex = target.to_hex();
1942 let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
1943 let may_strip = authority.roles.can_act_on_member(
1944 &my_pk.to_hex(),
1945 Some(&owner_hex),
1946 &target_hex,
1947 crate::community::roles::Permissions::MANAGE_ROLES,
1948 );
1949 if holds_roles && may_strip {
1950 grant_roles(transport, community, target, Vec::new())
1951 .await
1952 .map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
1953 }
1954 let at_ms = now_ms();
1955 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1956 let citation = required_authority_citation(community, &my_pk)?;
1959 let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
1960 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
1961 .map_err(|e| e.to_string())?;
1962 transport.publish(&wrap, &community.relays).await?;
1963 Ok(())
1964 })
1965 .await
1966}
1967
1968pub struct AuthorityView {
1974 pub roles: crate::community::roles::CommunityRoles,
1975 pub banned: std::collections::BTreeSet<String>,
1976 pub gapped: bool,
1978 pub floored: std::collections::BTreeSet<String>,
1980 pub head_entities: std::collections::BTreeSet<String>,
1982 pub banned_at: std::collections::BTreeMap<String, u64>,
1984}
1985
1986pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1992 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1993 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1994 .unwrap_or_default()
1995 .into_iter()
1996 .filter(|(_, f)| f.0 == community.root_epoch.0)
1997 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1998 .collect();
1999 let control = control::ControlPlane::of(community);
2000
2001 let mut editions: Vec<ParsedEdition> = Vec::new();
2002 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2003 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2004 let mut oldest: Option<u64> = None;
2005 let mut until: Option<u64> = None;
2006 let mut a = fold_authority(community, &[], &floors);
2011 for _ in 0..FOLLOW_MAX_PAGES {
2012 let query = Query {
2016 kinds: vec![stream::KIND_WRAP],
2017 authors: vec![control.pk_hex()],
2018 until,
2019 limit: Some(FOLLOW_PAGE),
2020 evidence: crate::community::transport::Evidence::Quorum,
2021 ..Default::default()
2022 };
2023 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
2024 let mut fresh = 0usize;
2025 for w in &wraps {
2026 if !seen_wraps.insert(w.id) {
2027 continue;
2028 }
2029 fresh += 1;
2030 let at = w.created_at.as_secs();
2031 if oldest.is_none_or(|o| at < o) {
2032 oldest = Some(at);
2033 }
2034 if let Ok((ed, _)) = control.open(w) {
2035 if seen.insert(ed.inner_id) {
2036 editions.push(ed);
2037 }
2038 }
2039 }
2040 a = fold_authority(community, &editions, &floors);
2041 if !a.gapped || fresh == 0 {
2042 break;
2043 }
2044 until = oldest;
2045 }
2046 AuthorityView {
2047 roles: a.roles,
2048 banned: a.banned,
2049 gapped: a.gapped,
2050 floored: floors.keys().cloned().collect(),
2051 head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
2052 banned_at: a.banned_at,
2053 }
2054}
2055
2056async fn fetch_guestbook_events<T: Transport + ?Sized>(
2068 transport: &T,
2069 community: &CommunityV2,
2070 since_secs: u64,
2071) -> Result<(Vec<guestbook::GuestbookEvent>, u64, bool), String> {
2072 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2073 const GB_PAGE: usize = 500;
2074 const GB_MAX_PAGES: usize = 12;
2075 let mut events = Vec::new();
2076 let mut newest: u64 = since_secs;
2077 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2078 let mut until: Option<u64> = None;
2079 let mut oldest: Option<u64> = None;
2080 let mut reached_end = false;
2084 for _ in 0..GB_MAX_PAGES {
2085 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() };
2088 let wraps = transport.fetch(&query, &community.relays).await?;
2089 let mut fresh = 0usize;
2090 for wrap in &wraps {
2091 if !seen.insert(wrap.id) {
2092 continue;
2093 }
2094 fresh += 1;
2095 let at = wrap.created_at.as_secs();
2096 if oldest.is_none_or(|o| at < o) {
2097 oldest = Some(at);
2098 }
2099 if at > newest {
2100 newest = at;
2101 }
2102 if at < since_secs {
2104 continue;
2105 }
2106 if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
2107 if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
2108 events.push(ev);
2109 }
2110 }
2111 }
2112 if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
2113 reached_end = true;
2114 break;
2115 }
2116 match oldest {
2117 Some(o) if o > 0 => until = Some(o),
2118 _ => {
2120 reached_end = true;
2121 break;
2122 }
2123 }
2124 }
2125 Ok((events, newest, reached_end))
2126}
2127
2128fn fold_members(
2133 community: &CommunityV2,
2134 events: &[guestbook::GuestbookEvent],
2135 mut observed: std::collections::BTreeMap<PublicKey, u64>,
2136 roles: &crate::community::roles::CommunityRoles,
2137 banlist: &std::collections::BTreeSet<PublicKey>,
2138 banned_at: &std::collections::BTreeMap<PublicKey, u64>,
2139) -> Result<Vec<PublicKey>, String> {
2140 let owner = community.owner()?;
2141 let owner_hex = owner.to_hex();
2142 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2143
2144 for g in &roles.grants {
2151 if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
2152 observed.entry(pk).or_insert(0);
2153 }
2154 }
2155
2156 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
2165 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2169 let actor_hex = actor.to_hex();
2170 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2171 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2172 };
2173 let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
2174 let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at);
2175 if !banlist.contains(&owner) {
2177 members.insert(owner);
2178 }
2179 Ok(members.into_iter().collect())
2180}
2181
2182pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
2192 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2193 let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
2194 return false;
2195 };
2196 let Ok(owner) = community.owner() else { return false };
2197 let owner_hex = owner.to_hex();
2198 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2199 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
2200 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2201 let actor_hex = actor.to_hex();
2202 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2203 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2204 };
2205 matches!(
2206 guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
2207 Some(st) if st.verdict == guestbook::Verdict::Kicked
2208 )
2209}
2210
2211pub async fn sync_guestbook<T: Transport + ?Sized>(
2216 transport: &T,
2217 community: &CommunityV2,
2218) -> Result<Vec<guestbook::GuestbookEvent>, String> {
2219 crate::db::scoped(async move {
2220 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2221 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2222 let full = !guestbook_walked_this_session(&cid_hex);
2230 let since = if full { 0 } else { cursor.saturating_sub(1) };
2233 let (fresh, newest, reached_end) = fetch_guestbook_events(transport, community, since).await?;
2234 let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
2235 let mut added = Vec::new();
2236 for ev in fresh {
2237 if !known.contains(&ev.rumor_id) {
2238 events.push(ev.clone());
2239 added.push(ev);
2240 }
2241 }
2242 let advanced = if reached_end { newest.max(cursor) } else { cursor };
2246 if !added.is_empty() || advanced > cursor {
2247 crate::db::community::set_guestbook(&cid_hex, &events, advanced)?;
2248 }
2249 if full && reached_end {
2250 mark_guestbook_walked(&cid_hex);
2251 }
2252 Ok(added)
2253 })
2254 .await
2255}
2256
2257struct GuestbookWalked;
2261
2262fn guestbook_walked_set() -> std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>> {
2263 crate::db::current_session().scoped::<GuestbookWalked, _>()
2264}
2265
2266fn guestbook_walked_this_session(community_id: &str) -> bool {
2267 guestbook_walked_set().lock().is_ok_and(|s| s.contains(community_id))
2268}
2269
2270fn mark_guestbook_walked(community_id: &str) {
2271 if let Ok(mut s) = guestbook_walked_set().lock() {
2272 s.insert(community_id.to_string());
2273 }
2274}
2275
2276pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
2279 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2280 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2281 if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
2282 return Ok(false);
2283 }
2284 events.push(ev);
2285 crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
2286 Ok(true)
2287}
2288
2289pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2296 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2297 let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2298 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2299 for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
2300 if let Ok(pk) = PublicKey::parse(&npub) {
2301 observed.insert(pk, last_active_secs.saturating_mul(1000));
2302 }
2303 }
2304 let roles = crate::db::community::get_community_roles(&cid_hex)?;
2305 let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2306 .unwrap_or_default()
2307 .iter()
2308 .filter_map(|h| PublicKey::from_hex(h).ok())
2309 .collect();
2310 let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
2313 .unwrap_or_default()
2314 .into_iter()
2315 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2316 .collect();
2317 fold_members(community, &events, observed, &roles, &banlist, &banned_at)
2318}
2319
2320pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2326 let (events, _newest, _reached_end) = fetch_guestbook_events(transport, community, 0).await?;
2327 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2330 for ch in &community.channels {
2331 if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
2332 for f in &page {
2333 let e = observed.entry(f.event.opened().author).or_insert(0);
2334 *e = (*e).max(f.event.opened().at_ms);
2335 }
2336 }
2337 }
2338
2339 let authority = fetch_authority(transport, community).await;
2344 let banlist: std::collections::BTreeSet<PublicKey> =
2346 authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
2347 let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
2351 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
2352 )
2353 .unwrap_or_default()
2354 .into_iter()
2355 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2356 .collect();
2357 for (h, at) in &authority.banned_at {
2358 if let Ok(pk) = PublicKey::from_hex(h) {
2359 let slot = banned_at.entry(pk).or_insert(0);
2360 *slot = (*slot).max(*at);
2361 }
2362 }
2363 fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
2364}
2365
2366pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2377 crate::db::scoped(async move {
2378 let signer = crate::signer::active_signer()?;
2379 let my_pk = me_pk()?;
2380 if community.owner()? != my_pk {
2381 return Err("only the owner can dissolve a community".to_string());
2382 }
2383 let at_ms = now_ms();
2384 let at = at_ms / 1000;
2385 let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
2386 let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
2387 transport.publish_durable(&wrap, &community.relays).await?;
2389 crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2390 if let Err(e) = tombstone_community_list(transport, community.id(), &community.relays, at_ms).await {
2399 crate::log_net_fail!("[CommunityList] dissolve tombstone failed ({}) — retrying in the background", e);
2400 tombstone_community_list_durable(*community.id(), community.relays.clone(), at_ms);
2401 }
2402 Ok(())
2403 })
2404 .await
2405}
2406
2407pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
2412 let group = super::derive::dissolved_group_key(community.id());
2413 let query = Query {
2414 kinds: vec![stream::KIND_WRAP],
2415 authors: vec![group.pk_hex()],
2416 limit: Some(20),
2417 ..Default::default()
2418 };
2419 let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
2420 return false;
2421 };
2422 wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
2423}
2424
2425pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
2438 crate::db::scoped(async move {
2439 let cid = community.id();
2440 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2441 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2443 return Err("this community has been dissolved; it cannot be re-founded".to_string());
2444 }
2445 let signer = crate::signer::active_signer()?;
2446 let my_pk = me_pk()?;
2447 let lock = super::realtime::follow_lock(cid);
2451 let _guard = lock.lock().await;
2452 let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
2456 let community = &fresh;
2457 let owner = community.owner()?;
2458
2459 {
2466 let owner_hex = owner.to_hex();
2467 let me_hex = my_pk.to_hex();
2468 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2474 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2475 let authorized = my_pk == owner
2476 || (!banned.contains(&me_hex)
2477 && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
2478 && removed.iter().all(|t| {
2479 roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
2480 }));
2481 if !authorized {
2482 return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
2483 }
2484 }
2485
2486 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2490 .into_iter()
2491 .filter(|(_, f)| f.0 == community.root_epoch.0)
2492 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2493 .collect();
2494 let current_control = control::ControlPlane::of(community);
2495 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2502 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2503 let mut oldest: Option<u64> = None;
2504 let mut until: Option<u64> = None;
2505 let mut truncated = false;
2509 for page in 0..COMPACT_MAX_PAGES {
2510 let query = Query {
2514 kinds: vec![stream::KIND_WRAP],
2515 authors: vec![current_control.pk_hex()],
2516 until,
2517 limit: Some(FOLLOW_PAGE),
2518 evidence: crate::community::transport::Evidence::Full,
2519 ..Default::default()
2520 };
2521 let wraps = transport.fetch(&query, &community.relays).await?;
2522 let mut fresh = 0usize;
2523 for w in &wraps {
2524 if !seen_wraps.insert(w.id) {
2525 continue;
2526 }
2527 fresh += 1;
2528 let at = w.created_at.as_secs();
2529 if oldest.is_none_or(|o| at < o) {
2530 oldest = Some(at);
2531 }
2532 if let Ok(parsed) = current_control.open(w) {
2533 opened.push(parsed);
2534 }
2535 }
2536 if fresh == 0 {
2537 truncated = wraps.len() >= FOLLOW_PAGE;
2541 break;
2542 }
2543 until = oldest;
2544 if page + 1 == COMPACT_MAX_PAGES {
2545 truncated = true;
2546 }
2547 }
2548 if truncated {
2549 return Err(
2550 "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(),
2551 );
2552 }
2553
2554 let prev_epoch = community.root_epoch;
2555 let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
2556 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2557 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2562 let new_control_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::CONTROL_ROOT_SCOPE, new_epoch.0)?;
2567 let new_control_pk = super::derive::control_signer_group_key(&new_control_root, cid, new_epoch).pk();
2568 let new_control = control::split_write_group(&new_control_root, &new_root, cid, new_epoch);
2574 let at = now_ms();
2575 let at_secs = at / 1000;
2576
2577 use std::collections::BTreeMap;
2583 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2584 for (i, (e, _)) in opened.iter().enumerate() {
2585 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2586 }
2587 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2588 for (floor_key, floor) in &floors {
2589 let head_idx = by_eid
2597 .get(floor_key)
2598 .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2599 let Some(head_idx) = head_idx else {
2600 return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
2601 };
2602 let (head_ed, head_os) = &opened[head_idx];
2603 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2604 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2605 carried.push((h, rewrapped));
2606 }
2607
2608 let members = memberlist(transport, community).await?;
2610 let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
2611 let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
2612 if !recipients.iter().any(|p| *p == my_pk) {
2613 recipients.push(my_pk);
2614 }
2615
2616 let mut roster_for_channels = fetch_authority(transport, community).await.roles;
2624 {
2625 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2626 for r in cached.roles {
2627 if !roster_for_channels.roles.iter().any(|x| x.role_id == r.role_id) {
2628 roster_for_channels.roles.push(r);
2629 }
2630 }
2631 for g in cached.grants {
2632 if !roster_for_channels.grants.iter().any(|x| x.member == g.member) {
2633 roster_for_channels.grants.push(g);
2634 }
2635 }
2636 }
2637 let owner_hex_for_channels = community.owner().ok().map(|o| o.to_hex());
2638
2639 let mut base_blobs = Vec::new();
2647 for r in &recipients {
2648 let staff = *r == my_pk || roster_for_channels.is_staff(&r.to_hex(), owner_hex_for_channels.as_deref());
2649 base_blobs.push(
2650 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))
2651 .await
2652 .map_err(|e| e.to_string())?,
2653 );
2654 }
2655 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2656 let base_chunks =
2657 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())
2658 .await
2659 .map_err(|e| e.to_string())?;
2660
2661 let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
2668 let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
2669 for ch in &community.channels {
2670 let (Some(old_key), true) = (ch.key, ch.private) else { continue };
2671 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
2672 let entitled: Vec<PublicKey> = recipients
2673 .iter()
2674 .copied()
2675 .filter(|r| {
2676 *r == my_pk
2677 || roster_for_channels.is_entitled(owner_hex_for_channels.as_deref(), &r.to_hex(), &ch_hex, &[], &[])
2678 })
2679 .collect();
2680 let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2681 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)?;
2685 let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
2686 let mut ch_blobs = Vec::new();
2687 for r in &entitled {
2688 ch_blobs.push(
2689 super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
2690 .await
2691 .map_err(|e| e.to_string())?,
2692 );
2693 }
2694 let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
2695 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())
2696 .await
2697 .map_err(|e| e.to_string())?;
2698 channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
2699 channel_chunk_sets.push(ch_chunks);
2700 }
2701
2702 for c in &base_chunks {
2705 transport.publish_durable(c, &community.relays).await?;
2706 }
2707 for set in &channel_chunk_sets {
2708 for c in set {
2709 transport.publish_durable(c, &community.relays).await?;
2710 }
2711 }
2712 for (_, wrap) in &carried {
2713 transport.publish_durable(wrap, &community.relays).await?;
2714 }
2715 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2718 let snap_id = crate::community::random_32();
2719 for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
2720 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
2721 let _ = transport.publish(&wrap, &community.relays).await;
2722 }
2723 }
2724
2725 if crate::db::community::community_protocol(cid)?.is_none() {
2727 return Ok(community.clone()); }
2729 let mut updated = community.clone();
2733 updated.community_root = new_root;
2734 updated.root_epoch = new_epoch;
2735 updated.control_pk = Some(new_control_pk);
2736 updated.control_root = Some(new_control_root);
2737 for (id, key, ep) in &channel_updates {
2738 if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
2739 c.key = Some(*key);
2740 c.epoch = *ep;
2741 }
2742 }
2743 crate::db::community::save_community_v2(&updated)?;
2744 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2749 for (h, _) in &carried {
2750 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2751 }
2752 if let Some(client) = crate::state::nostr_client() {
2758 super::realtime::refresh_subscription(&client).await;
2759 }
2760 for attempt in 0..3u8 {
2766 match refresh_public_links(transport, &updated).await {
2767 Ok(()) => break,
2768 Err(e) if attempt == 2 => {
2769 crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
2770 }
2771 Err(_) => continue,
2772 }
2773 }
2774 Ok(updated)
2775 })
2776 .await
2777}
2778
2779pub async fn refound_at_birth<T: Transport + ?Sized>(
2796 transport: &T,
2797 community: &CommunityV2,
2798 snapshot_members: &[PublicKey],
2799) -> Result<CommunityV2, String> {
2800 crate::db::scoped(async move {
2801 let cid = community.id();
2802 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2803 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2806 return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
2807 }
2808 let signer = crate::signer::active_signer()?;
2809 let my_pk = me_pk()?;
2810 if my_pk != community.owner()? {
2811 return Err("only the owner can birth-refound the migration twin".to_string());
2812 }
2813 let lock = super::realtime::follow_lock(cid);
2814 let _guard = lock.lock().await;
2815 let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
2816 if community.root_epoch.0 == 1 {
2822 return Ok(community);
2823 }
2824 if community.root_epoch.0 != 0 {
2825 return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
2826 }
2827 let community = &community;
2828
2829 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2833 .into_iter()
2834 .filter(|(_, f)| f.0 == community.root_epoch.0)
2835 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2836 .collect();
2837 let current_control = control::ControlPlane::of(community);
2838 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2839 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2840 let mut oldest: Option<u64> = None;
2841 let mut until: Option<u64> = None;
2842 let mut truncated = false;
2844 for page in 0..COMPACT_MAX_PAGES {
2845 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() };
2846 let wraps = transport.fetch(&query, &community.relays).await?;
2847 let mut fresh = 0usize;
2848 for w in &wraps {
2849 if !seen_wraps.insert(w.id) { continue; }
2850 fresh += 1;
2851 let at = w.created_at.as_secs();
2852 if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
2853 if let Ok(parsed) = current_control.open(w) {
2854 opened.push(parsed);
2855 }
2856 }
2857 if fresh == 0 {
2858 truncated = wraps.len() >= FOLLOW_PAGE;
2859 break;
2860 }
2861 until = oldest;
2862 if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
2863 }
2864 if truncated {
2865 return Err(
2866 "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(),
2867 );
2868 }
2869
2870 let prev_epoch = community.root_epoch; let new_epoch = Epoch(1);
2872 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2873 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2874 let new_control_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::CONTROL_ROOT_SCOPE, new_epoch.0)?;
2876 let new_control_pk = super::derive::control_signer_group_key(&new_control_root, cid, new_epoch).pk();
2877 let new_control = control::split_write_group(&new_control_root, &new_root, cid, new_epoch);
2878 let at = now_ms();
2879 let at_secs = at / 1000;
2880
2881 use std::collections::BTreeMap;
2882 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2883 for (i, (e, _)) in opened.iter().enumerate() {
2884 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2885 }
2886 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2887 for (floor_key, floor) in &floors {
2888 let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2889 let Some(head_idx) = head_idx else {
2890 return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
2891 };
2892 let (head_ed, head_os) = &opened[head_idx];
2893 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2894 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2895 carried.push((h, rewrapped));
2896 }
2897
2898 let base_blobs = vec![
2901 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))
2902 .await
2903 .map_err(|e| e.to_string())?,
2904 ];
2905 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2906 let base_chunks =
2907 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())
2908 .await
2909 .map_err(|e| e.to_string())?;
2910
2911 for c in &base_chunks {
2913 transport.publish_durable(c, &community.relays).await?;
2914 }
2915 for (_, wrap) in &carried {
2916 transport.publish_durable(wrap, &community.relays).await?;
2917 }
2918 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2926 let snap_id = crate::community::random_32();
2927 let snapshot_wraps: Vec<Event> = {
2928 let mut out = Vec::new();
2929 for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
2930 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
2931 .await
2932 .map_err(|e| format!("seal birth snapshot: {e}"))?;
2933 out.push(wrap);
2934 }
2935 out
2936 };
2937 for wrap in &snapshot_wraps {
2938 transport.publish_durable(wrap, &community.relays).await?;
2939 }
2940 let verify_view = {
2949 let mut v = community.clone();
2950 v.community_root = new_root;
2951 v.root_epoch = new_epoch;
2952 v.control_pk = Some(new_control_pk);
2953 v.control_root = Some(new_control_root);
2954 v
2955 };
2956 let expected: Vec<PublicKey> = {
2957 let banlist = fetch_authority(transport, &verify_view).await.banned;
2958 snapshot_members.iter().copied()
2959 .filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
2960 .collect()
2961 };
2962 if !expected.is_empty() {
2963 let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
2964 let missing = expected.iter().filter(|m| !folded.contains(m)).count();
2965 if missing > 0 {
2966 return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
2967 }
2968 }
2969
2970 if crate::db::community::community_protocol(cid)?.is_none() {
2972 return Ok(community.clone());
2973 }
2974 let mut updated = community.clone();
2975 updated.community_root = new_root;
2976 updated.root_epoch = new_epoch;
2977 updated.control_pk = Some(new_control_pk);
2978 updated.control_root = Some(new_control_root);
2979 crate::db::community::save_community_v2(&updated)?;
2980 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2981 for (h, _) in &carried {
2982 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2983 }
2984 Ok(updated)
2985 })
2986 .await
2987}
2988
2989fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
2996 if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
2997 return Ok(existing);
2998 }
2999 let fresh = crate::community::random_32();
3000 crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
3001 Ok(fresh)
3002}
3003
3004fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
3010 let hex = crate::simd::hex::bytes_to_hex_32;
3011 let channels = community
3012 .channels
3013 .iter()
3014 .filter(|c| c.private)
3015 .filter_map(|c| {
3019 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() })
3020 })
3021 .collect();
3022 super::list::JoinMaterial {
3023 community_id: hex(&community.identity.community_id.0),
3024 owner: hex(&community.identity.owner_xonly),
3025 owner_salt: hex(&community.identity.owner_salt),
3026 community_root: hex(&community.community_root),
3027 root_epoch: community.root_epoch.0,
3028 control_pk: community.control_pk.map(|p| p.to_hex()),
3029 control_root: community.control_root.map(|r| hex(&r)),
3033 channels,
3034 relays: community.relays.clone(),
3035 name: community.name.clone(),
3036 extra: Default::default(),
3037 }
3038}
3039
3040fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
3044 let channels = jm
3047 .channels
3048 .iter()
3049 .filter_map(|c| {
3050 c.key.as_ref().map(|k| invite::ChannelGrant { id: c.id.clone(), key: k.clone(), epoch: c.epoch, name: c.name.clone() })
3051 })
3052 .collect();
3053 CommunityInvite {
3054 community_id: jm.community_id.clone(),
3055 owner: jm.owner.clone(),
3056 owner_salt: jm.owner_salt.clone(),
3057 community_root: jm.community_root.clone(),
3058 root_epoch: jm.root_epoch,
3059 control_pk: jm.control_pk.clone(),
3060 channels,
3061 relays: jm.relays.clone(),
3062 name: jm.name.clone(),
3063 icon: None,
3064 expires_at: None,
3065 creator_npub: None,
3066 label: None,
3067 extra: Default::default(),
3068 }
3069}
3070
3071async fn own_list_relays() -> Vec<String> {
3076 let Some(client) = crate::state::nostr_client() else { return Vec::new() };
3077 client
3078 .relays()
3079 .await
3080 .iter()
3081 .filter(|(_, r)| r.capabilities().load().can_write())
3082 .map(|(url, _)| url.to_string())
3083 .collect()
3084}
3085
3086async fn list_read_relays(extra: &[String]) -> Vec<String> {
3091 let mut set = own_list_relays().await;
3092 set.extend(invite::stock_relays());
3093 set.extend(held_v2_relays());
3094 set.extend(extra.iter().cloned());
3095 set.sort();
3096 set.dedup();
3097 set
3098}
3099
3100pub struct FragSet {
3103 pub list: super::list::CommunityList,
3104 pub created_at: std::collections::BTreeMap<usize, u64>,
3105 pub declared: usize,
3107 pub read_frags: std::collections::BTreeMap<usize, super::list_frag::FragList>,
3110}
3111
3112impl FragSet {
3113 pub fn is_complete(&self) -> bool {
3116 (0..self.declared).all(|i| self.created_at.contains_key(&i))
3117 }
3118}
3119
3120async fn fetch_fragments<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<FragSet>, String> {
3123 let signer = crate::signer::active_signer()?;
3124 let my_pk = me_pk()?;
3125 let query = Query {
3126 kinds: vec![super::kind::COMMUNITY_LIST_FRAG],
3127 authors: vec![my_pk.to_hex()],
3128 limit: Some(64),
3129 ..Default::default()
3130 };
3131 let events = transport.fetch(&query, relays).await?;
3132 if events.is_empty() {
3133 return Ok(None);
3134 }
3135 let mut newest: std::collections::BTreeMap<usize, (u64, [u8; 32], super::list_frag::FragList)> = Default::default();
3136 let mut undecryptable = 0usize;
3137 for e in events {
3138 let at = e.created_at.as_secs();
3139 let id = *e.id.as_bytes();
3140 match super::list_frag::parse_fragment_event(&signer, my_pk, &e).await {
3141 Ok((index, frag)) => {
3142 let wins = match newest.get(&index) {
3145 None => true,
3146 Some((prev_at, prev_id, _)) => at > *prev_at || (at == *prev_at && id < *prev_id),
3147 };
3148 if wins {
3149 newest.insert(index, (at, id, frag));
3150 }
3151 }
3152 Err(_) => undecryptable += 1,
3153 }
3154 }
3155 if newest.is_empty() {
3156 crate::log_warn!("[CommunityList] {} fragment(s) fetched, none readable — treating as no news", undecryptable);
3157 return Ok(None);
3158 }
3159 let declared = newest
3163 .values()
3164 .max_by_key(|(at, _, f)| (*at, f.frags))
3165 .map(|(_, _, f)| f.frags)
3166 .unwrap_or(1)
3167 .max(1);
3168 let read_frags: std::collections::BTreeMap<usize, super::list_frag::FragList> =
3169 newest.iter().map(|(i, (_, _, f))| (*i, f.clone())).collect();
3170 let frags: Vec<_> = read_frags.values().cloned().collect();
3171 let set = FragSet {
3172 list: super::list_frag::defragment(&frags),
3173 created_at: newest.iter().map(|(i, (at, _, _))| (*i, *at)).collect(),
3174 declared,
3175 read_frags,
3176 };
3177 if !set.is_complete() {
3178 crate::log_warn!(
3179 "[CommunityList] INCOMPLETE: hold {} of {} fragment(s) — reading, refusing to write",
3180 set.created_at.len(),
3181 declared
3182 );
3183 }
3184 Ok(Some(set))
3185}
3186
3187async fn publish_fragments<T: Transport + ?Sized>(
3192 transport: &T,
3193 list: &super::list::CommunityList,
3194 prev: &std::collections::BTreeMap<usize, u64>,
3195 read: &std::collections::BTreeMap<usize, super::list_frag::FragList>,
3196 own: &[String],
3197) -> Result<usize, String> {
3198 let signer = crate::signer::active_signer()?;
3199 let my_pk = me_pk()?;
3200 let frags = super::list_frag::fragment(list);
3201 let now = now_ms() / 1000;
3202 let unchanged = |index: usize, frag: &super::list_frag::FragList| -> bool {
3206 let Some(old) = read.get(&index) else { return false };
3207 matches!(
3208 (serde_json::to_string(old), serde_json::to_string(frag)),
3209 (Ok(a), Ok(b)) if a == b
3210 )
3211 };
3212 crate::log_net_info!(
3215 "[CommunityList] publishing {} fragment(s): {} live entries ({} retired), {} tombstones",
3216 frags.len(),
3217 frags.iter().map(|f| f.entries.len()).sum::<usize>(),
3218 list.entries.len() - frags.iter().map(|f| f.entries.len()).sum::<usize>(),
3219 frags.iter().map(|f| f.tombstones.len()).sum::<usize>(),
3220 );
3221 let mut skipped = 0usize;
3222 for (index, frag) in frags.iter().enumerate() {
3223 if unchanged(index, frag) {
3224 skipped += 1;
3225 continue;
3226 }
3227 let created_at = now.max(prev.get(&index).copied().unwrap_or(0) + 1);
3228 let event = super::list_frag::build_fragment_event(&signer, my_pk, frag, index, created_at).await?;
3229 publish_list_event(transport, &event, own).await?;
3230 }
3231 for index in frags.len()..prev.len() {
3234 let empty = super::list_frag::FragList {
3235 frags: frags.len(),
3236 entries: vec![],
3237 tombstones: vec![],
3238 extra: Default::default(),
3239 };
3240 if unchanged(index, &empty) {
3241 skipped += 1;
3242 continue;
3243 }
3244 let created_at = now.max(prev.get(&index).copied().unwrap_or(0) + 1);
3245 let event = super::list_frag::build_fragment_event(&signer, my_pk, &empty, index, created_at).await?;
3246 publish_list_event(transport, &event, own).await?;
3247 }
3248 if skipped > 0 {
3249 crate::log_net_info!("[CommunityList] {} fragment(s) skipped — byte-identical to the relay copy", skipped);
3250 }
3251 Ok(frags.len())
3252}
3253
3254async fn publish_list_event<T: Transport + ?Sized>(transport: &T, event: &Event, own: &[String]) -> Result<(), String> {
3258 if !own.is_empty() {
3259 match transport.publish(event, own).await {
3260 Ok(()) => return Ok(()),
3261 Err(e) => crate::log_warn!("[CommunityList] own relays refused the list ({}) — falling back to the stock set", e),
3262 }
3263 }
3264 transport.publish(event, &invite::stock_relays()).await
3265}
3266
3267fn held_v2_relays() -> Vec<String> {
3270 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3271 if let Ok(ids) = crate::db::community::list_community_ids() {
3272 for id in ids {
3273 if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
3274 if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
3275 set.extend(c.relays);
3276 }
3277 }
3278 }
3279 }
3280 set.into_iter().collect()
3281}
3282
3283pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
3295 crate::db::scoped(async move {
3296 let held = held_v2_relays();
3297 if held.is_empty() {
3298 return Ok(false); }
3300 let relays = list_read_relays(&[]).await;
3301 let own = own_list_relays().await;
3302 let write_relays = if own.is_empty() { held.clone() } else { own };
3306 let (remote, prev_created, read_frags) = match fetch_fragments(transport, &relays).await {
3310 Ok(None) => (super::list::CommunityList::default(), Default::default(), Default::default()),
3313 Ok(Some(set)) if !set.is_complete() => {
3314 crate::log_net_fail!(
3315 "[CommunityList] republish SKIPPED — hold {} of {} fragments; rewriting a set we haven't fully read would drop the memberships in the ones we're missing",
3316 set.created_at.len(),
3317 set.declared
3318 );
3319 return Ok(false);
3320 }
3321 Ok(Some(set)) => (set.list, set.created_at, set.read_frags),
3322 Err(e) => {
3323 crate::log_warn!(
3329 "[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
3330 e,
3331 just_joined
3332 .map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
3333 .unwrap_or_default()
3334 );
3335 return Ok(false);
3336 }
3337 };
3338 let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
3339 let now = now_ms();
3340 let mut local = super::list::CommunityList::default();
3341 for id in crate::db::community::list_community_ids()? {
3342 if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
3343 continue;
3344 }
3345 let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
3346 let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
3347 let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
3348 let tombstoned_at = remote
3360 .tombstones
3361 .iter()
3362 .find(|t| t.community_id == cid_hex)
3363 .map(|t| t.removed_at)
3364 .unwrap_or(0);
3365 let held_since = c.created_at_ms;
3366 if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
3367 crate::log_warn!(
3368 "[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
3369 &cid_hex[..8], tombstoned_at, held_since
3370 );
3371 continue;
3372 }
3373 let added_at = if remote.is_live(&cid_hex) && !is_join {
3378 remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
3379 } else if !is_join && tombstoned_at > 0 {
3380 held_since
3381 } else {
3382 now
3383 };
3384 let jm = join_material(&c);
3385 local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
3386 }
3387 let merged = remote.merge(&local);
3388 match publish_fragments(transport, &merged, &prev_created, &read_frags, &write_relays).await {
3391 Ok(_) => {}
3392 Err(e) => {
3393 crate::log_net_fail!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
3394 return Err(e);
3395 }
3396 }
3397 Ok(true)
3398 })
3399 .await
3400}
3401
3402const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
3407
3408pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
3421 if crate::state::nostr_client().is_none() {
3422 return;
3423 }
3424 crate::db::spawn_bound(async move {
3425 for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
3426 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3427 match republish_community_list(&transport, just_joined.as_ref()).await {
3428 Ok(true) => {
3429 if attempt > 0 {
3430 crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
3431 }
3432 return;
3433 }
3434 Ok(false) => {} Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
3436 }
3437 tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
3438 }
3439 crate::log_warn!(
3440 "[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
3441 LIST_REPUBLISH_BACKOFF_SECS.len()
3442 );
3443 });
3444}
3445
3446async fn tombstone_community_list<T: Transport + ?Sized>(
3451 transport: &T,
3452 community_id: &crate::community::CommunityId,
3453 relays: &[String],
3454 removed_at: u64,
3455) -> Result<(), String> {
3456 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3457 let read_relays = list_read_relays(relays).await;
3458 let own = own_list_relays().await;
3459 let write_relays = if own.is_empty() { relays.to_vec() } else { own };
3460 let (mut doc, prev_created, read_frags) = match fetch_fragments(transport, &read_relays).await {
3464 Ok(Some(set)) if !set.is_complete() => {
3465 return Err(format!("hold {} of {} fragments — refusing to rewrite a set we haven't fully read", set.created_at.len(), set.declared));
3466 }
3467 Ok(Some(set)) => (set.list, set.created_at, set.read_frags),
3468 Ok(None) => (super::list::CommunityList::default(), Default::default(), Default::default()),
3469 Err(e) => return Err(e),
3470 };
3471 doc.tombstones.retain(|t| t.community_id != cid_hex);
3472 doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at, extra: Default::default() });
3473 publish_fragments(transport, &doc, &prev_created, &read_frags, &write_relays).await.map(|_| ())
3477}
3478
3479pub fn tombstone_community_list_durable(community_id: crate::community::CommunityId, relays: Vec<String>, removed_at: u64) {
3487 if crate::state::nostr_client().is_none() {
3488 return;
3489 }
3490 crate::db::spawn_bound(async move {
3491 for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
3492 tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
3493 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3494 match tombstone_community_list(&transport, &community_id, &relays, removed_at).await {
3495 Ok(()) => {
3496 crate::log_net_info!(
3497 "[CommunityList] leave tombstone for {} landed on retry #{}",
3498 &crate::simd::hex::bytes_to_hex_32(&community_id.0)[..8],
3499 attempt + 1
3500 );
3501 return;
3502 }
3503 Err(e) => crate::log_warn!("[CommunityList] leave tombstone retry #{} failed: {}", attempt + 1, e),
3504 }
3505 }
3506 crate::log_net_fail!(
3507 "[CommunityList] leave tombstone for {} NEVER landed after {} attempts — it will come back on another device",
3508 &crate::simd::hex::bytes_to_hex_32(&community_id.0)[..8],
3509 LIST_REPUBLISH_BACKOFF_SECS.len()
3510 );
3511 });
3512}
3513
3514pub struct ListSyncOutcome {
3521 pub joined: Vec<CommunityV2>,
3523 pub removed: Vec<(String, Vec<String>)>,
3530}
3531
3532const LIST_SEEDED_KEY: &str = "community_list_frag_seeded";
3534
3535pub const ERR_DISSOLVED: &str = "this community has been dissolved";
3540
3541pub async fn retire_dead_invite<T: Transport + ?Sized>(transport: &T, community_id_hex: &str, relays: &[String]) {
3545 let _ = crate::db::community::delete_pending_invite(community_id_hex);
3546 if let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(community_id_hex) {
3547 let id = crate::community::CommunityId(cid);
3548 let at = now_ms();
3549 if tombstone_community_list(transport, &id, &relays.to_vec(), at).await.is_err() {
3550 tombstone_community_list_durable(id, relays.to_vec(), at);
3553 }
3554 }
3555 crate::emit_event("community_invites_purged", &serde_json::json!({}));
3556}
3557
3558async fn confirmed_no_fragments<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> bool {
3563 let Ok(my_pk) = me_pk() else { return false };
3564 if relays.is_empty() {
3565 return false;
3566 }
3567 let query = Query {
3568 kinds: vec![super::kind::COMMUNITY_LIST_FRAG],
3569 authors: vec![my_pk.to_hex()],
3570 limit: Some(1),
3571 ..Default::default()
3572 };
3573 for relay in relays {
3574 match transport.fetch(&query, std::slice::from_ref(relay)).await {
3575 Ok(events) if events.is_empty() => {}
3576 _ => return false,
3579 }
3580 }
3581 true
3582}
3583
3584async fn seed_community_list<T: Transport + ?Sized>(transport: &T) {
3590 if crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).ok().flatten().is_some() {
3591 return;
3592 }
3593 if held_v2_relays().is_empty() {
3594 return;
3595 }
3596 let own = own_list_relays().await;
3599 let confirm = if own.is_empty() { held_v2_relays() } else { own };
3600 if !confirmed_no_fragments(transport, &confirm).await {
3601 crate::log_warn!("[CommunityList] seed DEFERRED — the empty read is unconfirmed; retrying on a later boot");
3602 return;
3603 }
3604 crate::log_net_info!("[CommunityList] no fragments held anywhere (confirmed per relay) — seeding from local state");
3605 match republish_community_list(transport, None).await {
3606 Ok(true) => {
3607 let _ = crate::db::settings::set_sql_setting(LIST_SEEDED_KEY.to_string(), "1".to_string());
3608 }
3609 Ok(false) => {}
3610 Err(e) => crate::log_net_fail!("[CommunityList] seed failed: {e}"),
3611 }
3612}
3613
3614pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
3615 crate::db::scoped(async move {
3616 let relays = list_read_relays(bootstrap_relays).await;
3617 if relays.is_empty() {
3618 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3619 }
3620 let list = match fetch_fragments(transport, &relays).await {
3624 Ok(Some(set)) => {
3625 crate::log_net_info!(
3626 "[CommunityList] fetched: {} of {} fragment(s), {} entries, {} tombstones, across {} relays",
3627 set.created_at.len(),
3628 set.declared,
3629 set.list.entries.len(),
3630 set.list.tombstones.len(),
3631 relays.len()
3632 );
3633 set.list
3634 }
3635 Ok(None) => {
3636 crate::log_debug!("[CommunityList] no fragments across {} relays", relays.len());
3640 seed_community_list(transport).await;
3641 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3642 }
3643 Err(e) => {
3644 crate::log_net_fail!("[CommunityList] fetch failed across {} relays: {e}", relays.len());
3645 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3646 }
3647 };
3648 let mut removed: Vec<(String, Vec<String>)> = Vec::new();
3654 for t in &list.tombstones {
3655 if list.is_live(&t.community_id) {
3656 continue;
3657 }
3658 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
3659 let id = crate::community::CommunityId(cid);
3660 let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
3661 continue; };
3663 if held.created_at_ms > t.removed_at {
3669 crate::log_warn!(
3670 "[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
3671 &t.community_id[..8], t.removed_at, held.created_at_ms
3672 );
3673 continue;
3674 }
3675 let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
3676 let _ = crate::db::community::delete_community(&t.community_id);
3677 removed.push((t.community_id.clone(), channel_ids));
3678 }
3679 let mut invites_purged = 0usize;
3685 for t in &list.tombstones {
3686 if list.is_live(&t.community_id) {
3687 continue;
3688 }
3689 let Ok(Some(received_at)) = crate::db::community::pending_invite_received_at(&t.community_id) else { continue };
3690 if (received_at.max(0) as u64).saturating_mul(1000) <= t.removed_at
3691 && crate::db::community::delete_pending_invite(&t.community_id).is_ok()
3692 {
3693 invites_purged += 1;
3694 }
3695 }
3696 if invites_purged > 0 {
3697 crate::emit_event("community_invites_purged", &serde_json::json!({}));
3698 }
3699 let mut joined = Vec::new();
3700 for entry in list.live_entries() {
3701 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
3702 if crate::db::community::community_exists(&crate::community::CommunityId(cid)).unwrap_or(false) {
3707 continue; }
3709 let bundle = material_to_invite(&entry.current);
3714 match accept_bundle(transport, &bundle, None, false).await {
3715 Ok(community) => {
3716 if let (Some(pk), Some(root_hex)) = (community.control_pk, entry.current.control_root.as_deref()) {
3722 if let Some(root) = crate::simd::hex::hex_to_bytes_32_checked(root_hex) {
3723 if super::derive::control_signer_group_key(&root, community.id(), community.root_epoch).pk() == pk
3724
3725 {
3726 let mut held = community.clone();
3727 held.control_root = Some(root);
3728 let _ = crate::db::community::save_community_v2(&held);
3729 }
3730 }
3731 }
3732 joined.push(community);
3733 }
3734 Err(e) => crate::log_net_fail!(
3738 "[CommunityList] {} is listed but adoption failed: {e}",
3739 &entry.community_id[..entry.community_id.len().min(8)]
3740 ),
3741 }
3742 }
3743 Ok(ListSyncOutcome { joined, removed })
3744 })
3745 .await
3746}
3747
3748pub(super) fn citation_is_synced(
3778 cid_hex: &str,
3779 owner_hex: &str,
3780 actor_hex: &str,
3781 citation: Option<&crate::community::edition::AuthorityCitation>,
3782) -> bool {
3783 if owner_hex == actor_hex {
3784 return true;
3785 }
3786 if citation.is_none() {
3787 return false;
3788 }
3789 let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
3790 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
3791 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
3792 &crate::community::CommunityId(cid_bytes),
3793 &actor_bytes,
3794 ));
3795 let head: Vec<crate::community::roster::EntityHead> =
3796 crate::db::community::get_edition_head(cid_hex, &grant_hex)
3797 .ok()
3798 .flatten()
3799 .map(|(version, self_hash)| crate::community::roster::EntityHead {
3800 entity_hex: grant_hex.clone(),
3801 version,
3802 self_hash,
3803 inner_id: [0u8; 32],
3804 citation: None,
3805 })
3806 .into_iter()
3807 .collect();
3808 crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
3809}
3810
3811fn required_authority_citation(
3819 community: &CommunityV2,
3820 actor: &PublicKey,
3821) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
3822 if community.owner().ok().as_ref() == Some(actor) {
3823 return Ok(None); }
3825 my_authority_citation(community, actor).map(Some).ok_or_else(|| {
3826 "your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
3827 })
3828}
3829
3830fn my_authority_citation(
3831 community: &CommunityV2,
3832 actor: &PublicKey,
3833) -> Option<crate::community::edition::AuthorityCitation> {
3834 if community.owner().ok().as_ref() == Some(actor) {
3835 return None;
3836 }
3837 let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
3838 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3839 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
3840 crate::db::community::get_edition_head(&cid_hex, &entity_hex)
3841 .ok()
3842 .flatten()
3843 .map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
3844}
3845
3846fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
3854 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3855 match crate::db::community::get_server_root_epoch(&cid_hex)? {
3856 Some(held) if held != community.root_epoch.0 => Err(format!(
3857 "the community re-founded mid-action (epoch {} -> {held}); retry",
3858 community.root_epoch.0
3859 )),
3860 _ => Ok(()), }
3862}
3863
3864async fn publish_control_edition<T: Transport + ?Sized>(
3865 transport: &T,
3866 community: &CommunityV2,
3867 vsk: &str,
3868 entity_id: &[u8; 32],
3869 content: &str,
3870) -> Result<(), String> {
3871 crate::db::scoped(async move {
3872 assert_current_root(community)?;
3873 let signer = crate::signer::active_signer()?;
3874 let my_pk = me_pk()?;
3875 let view = control::ControlPlane::of(community);
3879 let control = view.write_group()?;
3880 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3881 let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
3882 let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
3883 Some((v, h)) => (v + 1, Some(h)),
3884 None => (1, None),
3885 };
3886 let citation = required_authority_citation(community, &my_pk)?;
3892 let at = now_ms() / 1000;
3893 let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
3894 let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
3895 transport.publish(&wrap, &community.relays).await?;
3896 if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
3902 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
3903 }
3904 Ok(())
3905 })
3906 .await
3907}
3908
3909fn merge_local_roster(cid_hex: &str, role: Option<&crate::community::roles::Role>, grant: Option<&crate::community::roles::MemberGrant>) {
3918 let mut roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
3919 if let Some(r) = role {
3920 match roster.roles.iter_mut().find(|x| x.role_id == r.role_id) {
3921 Some(slot) => *slot = r.clone(),
3922 None => roster.roles.push(r.clone()),
3923 }
3924 }
3925 if let Some(g) = grant {
3926 match roster.grants.iter_mut().find(|x| x.member == g.member) {
3927 Some(slot) => *slot = g.clone(),
3928 None => roster.grants.push(g.clone()),
3929 }
3930 }
3931 let at = crate::db::community::get_community_roles_at(cid_hex).unwrap_or(0);
3932 if let Err(e) = crate::db::community::set_community_roles(cid_hex, &roster, at) {
3933 crate::log_warn!("v2: local roster merge failed (heals on the next control fold): {e}");
3934 }
3935}
3936
3937pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
3941 super::roles::validate_role(role)?;
3942 let content = super::roles::role_content_json(role)?;
3943 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
3944 publish_control_edition(transport, community, vsk::ROLE, &role_id, &content).await
3945}
3946
3947pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
3953 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3954 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3955 let roster = if community.control_pk.is_some() && role_ids.iter().any(|rid| roster.role(rid).is_none()) {
3963 let mut fetched = fetch_authority(transport, community).await.roles;
3964 for r in roster.roles {
3965 if !fetched.roles.iter().any(|x| x.role_id == r.role_id) {
3966 fetched.roles.push(r);
3967 }
3968 }
3969 fetched
3970 } else {
3971 roster
3972 };
3973 grant_roles_with_roster(transport, community, member, role_ids, &roster).await
3974}
3975
3976async fn control_wrap_owed(community: &CommunityV2, makes_staff: bool, member: &PublicKey) -> Result<Option<String>, String> {
3991 if !makes_staff {
3992 return Ok(None);
3993 }
3994 let Some(pk) = community.control_pk else {
3995 return Ok(None); };
3997 let Some(cr) = community.control_root else {
3998 return Err("you don't hold this community's staff write key, so you can't promote to staff yet".to_string());
3999 };
4000 if super::derive::control_signer_group_key(&cr, community.id(), community.root_epoch).pk() != pk {
4003 return Err("this community's held staff write key is corrupt; re-sync before promoting".to_string());
4004 }
4005 use nostr_sdk::prelude::AsyncNip44;
4006 let signer = crate::signer::active_signer()?;
4007 let wrap = signer
4008 .nip44_encrypt_async(member, &super::rekey::control_wrap_b64(community.root_epoch, &cr))
4009 .await
4010 .map_err(|e| format!("staff key delivery: {e}"))?;
4011 Ok(Some(wrap))
4012}
4013
4014async fn grant_roles_with_roster<T: Transport + ?Sized>(
4018 transport: &T,
4019 community: &CommunityV2,
4020 member: &PublicKey,
4021 role_ids: Vec<String>,
4022 roster: &crate::community::roles::CommunityRoles,
4023) -> Result<(), String> {
4024 let control_wrap = control_wrap_owed(community, roster.roles_make_staff(&role_ids), member).await?;
4028 let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
4029 let content = super::roles::grant_content_json_with_wrap(&grant, control_wrap)?;
4030 let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
4031 publish_control_edition(transport, community, vsk::GRANT, &eid, &content).await
4032}
4033
4034pub async fn ensure_admin_role<T: Transport + ?Sized>(
4039 transport: &T,
4040 community: &CommunityV2,
4041 view: &AuthorityView,
4042 create_if_missing: bool,
4043) -> Result<Option<String>, String> {
4044 use crate::community::roles::{Permissions, Role, RoleScope};
4045 if let Some(r) = view
4049 .roles
4050 .roles
4051 .iter()
4052 .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK))
4053 {
4054 return Ok(Some(r.role_id.clone()));
4055 }
4056 if !create_if_missing {
4057 return Ok(None);
4058 }
4059 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4060 let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
4061 set_role(transport, community, &Role::admin(role_id.clone())).await?;
4062 Ok(Some(role_id))
4063}
4064
4065pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
4072 crate::db::scoped(async move {
4073 let my_pk = me_pk()?;
4076 if my_pk != community.owner()? {
4077 return Err("only the community owner can grant @admin".to_string());
4078 }
4079 let view = fetch_authority(transport, community).await;
4080 let member_hex = member.to_hex();
4081 require_grant_head(community, &view, &member_hex)?;
4082 let role_id = ensure_admin_role(transport, community, &view, true)
4083 .await?
4084 .expect("create_if_missing yields an id");
4085 let mut role_ids = view
4086 .roles
4087 .grants
4088 .iter()
4089 .find(|g| g.member == member_hex)
4090 .map(|g| g.role_ids.clone())
4091 .unwrap_or_default();
4092 if role_ids.contains(&role_id) {
4093 return Ok(()); }
4095 role_ids.push(role_id.clone());
4096 let mut roster = view.roles.clone();
4100 if roster.role(&role_id).is_none() {
4101 roster.roles.push(crate::community::roles::Role::admin(role_id));
4102 }
4103 grant_roles_with_roster(transport, community, member, role_ids, &roster).await
4104 })
4105 .await
4106}
4107
4108pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
4111 crate::db::scoped(async move {
4112 let my_pk = me_pk()?;
4113 if my_pk != community.owner()? {
4114 return Err("only the community owner can revoke @admin".to_string());
4115 }
4116 let view = fetch_authority(transport, community).await;
4117 let member_hex = member.to_hex();
4118 require_grant_head(community, &view, &member_hex)?;
4119 let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
4120 return Ok(()); };
4122 let mut role_ids = view
4123 .roles
4124 .grants
4125 .iter()
4126 .find(|g| g.member == member_hex)
4127 .map(|g| g.role_ids.clone())
4128 .unwrap_or_default();
4129 let before = role_ids.len();
4130 role_ids.retain(|r| r != &role_id);
4131 if role_ids.len() == before {
4132 return Ok(());
4133 }
4134 grant_roles_with_roster(transport, community, member, role_ids, &view.roles).await
4137 })
4138 .await
4139}
4140
4141fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
4145 let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
4146 return Err("malformed member key".to_string());
4147 };
4148 let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
4149 if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
4150 return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
4151 }
4152 Ok(())
4153}
4154
4155pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
4162 crate::db::scoped(async move {
4163 super::roles::validate_banlist(banned)?;
4164 {
4165 let my_pk = me_pk()?;
4166 let owner = community.owner()?;
4167 if my_pk != owner {
4168 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4169 let me_hex = my_pk.to_hex();
4170 let current = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4171 if current.contains(&me_hex) {
4172 return Err("you are banned from this community".to_string());
4173 }
4174 let roster = crate::db::community::get_community_roles(&cid_hex)?;
4175 let owner_hex = owner.to_hex();
4176 if !roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN) {
4177 return Err("editing the banlist needs the BAN permission".to_string());
4178 }
4179 for target in banned.iter().filter(|t| !current.contains(*t)) {
4182 if !roster.can_act_on_member(&me_hex, Some(&owner_hex), target, crate::community::roles::Permissions::BAN) {
4183 return Err("you do not outrank a member this ban targets".to_string());
4184 }
4185 }
4186 }
4187 }
4188 let content = super::roles::banlist_content_json(banned)?;
4189 let eid = super::derive::banlist_locator(community.id());
4190 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4191 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
4192 let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
4194 Some((v, _)) => v + 1,
4195 None => 1,
4196 };
4197 publish_control_edition(transport, community, vsk::BANLIST, &eid, &content).await?;
4198 let _ = crate::db::community::set_community_banlist(&cid_hex, banned, version as i64);
4205 Ok(())
4206 })
4207 .await
4208}
4209
4210struct ModerationLocks;
4213fn moderation_lock(cid: &crate::community::CommunityId) -> std::sync::Arc<tokio::sync::Mutex<()>> {
4214 let map = crate::db::current_session()
4215 .scoped::<ModerationLocks, std::sync::Mutex<std::collections::HashMap<[u8; 32], std::sync::Arc<tokio::sync::Mutex<()>>>>>();
4216 let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
4217 m.entry(cid.0).or_default().clone()
4218}
4219
4220async fn converge_authority<T: Transport + ?Sized>(transport: &T, id: &crate::community::CommunityId) {
4224 if let Ok(Some(fresh)) = crate::db::community::load_community_v2(id) {
4225 let _ = follow_control(transport, &fresh).await;
4226 if let Ok(added) = sync_guestbook(transport, &fresh).await {
4230 if !added.is_empty() {
4231 crate::traits::emit_event_json(
4232 "community_refreshed",
4233 serde_json::json!({ "community_id": crate::simd::hex::bytes_to_hex_32(&id.0) }),
4234 );
4235 }
4236 }
4237 }
4238}
4239
4240struct ModIntent {
4242 id: u64,
4243 targets: Vec<PublicKey>,
4244 banned: bool,
4245 done: tokio::sync::oneshot::Sender<Result<(), String>>,
4246}
4247
4248struct PendingModeration;
4251fn pending_moderation(cid: &crate::community::CommunityId) -> std::sync::Arc<std::sync::Mutex<std::collections::HashMap<[u8; 32], Vec<ModIntent>>>> {
4252 let _ = cid;
4253 crate::db::current_session()
4254 .scoped::<PendingModeration, std::sync::Mutex<std::collections::HashMap<[u8; 32], Vec<ModIntent>>>>()
4255}
4256
4257pub async fn set_members_banned<T: Transport + ?Sized>(
4278 transport: &T,
4279 community_id: &crate::community::CommunityId,
4280 members: &[PublicKey],
4281 banned: bool,
4282) -> Result<(), String> {
4283 crate::db::scoped(async move {
4284 let mut targets: Vec<PublicKey> = Vec::with_capacity(members.len());
4286 for m in members {
4287 if !targets.contains(m) {
4288 targets.push(*m);
4289 }
4290 }
4291 if targets.is_empty() {
4292 return Ok(());
4293 }
4294 static INTENT_IDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
4296 let my_id = INTENT_IDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4297 let (tx, rx) = tokio::sync::oneshot::channel();
4298 {
4299 let map = pending_moderation(community_id);
4300 let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
4301 m.entry(community_id.0).or_default().push(ModIntent { id: my_id, targets, banned, done: tx });
4302 }
4303 let unit = moderation_lock(community_id);
4304 tokio::select! {
4307 biased;
4308 res = rx => res.unwrap_or_else(|_| Err("moderation unit aborted before completing".to_string())),
4309 _guard = unit.lock() => {
4310 let batch: Vec<ModIntent> = {
4313 let map = pending_moderation(community_id);
4314 let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
4315 m.remove(&community_id.0).unwrap_or_default()
4316 };
4317 run_moderation_batch(transport, community_id, batch, my_id).await
4318 }
4319 }
4320 })
4321 .await
4322}
4323
4324async fn run_moderation_batch<T: Transport + ?Sized>(
4329 transport: &T,
4330 community_id: &crate::community::CommunityId,
4331 batch: Vec<ModIntent>,
4332 leader_id: u64,
4333) -> Result<(), String> {
4334 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
4335
4336 converge_authority(transport, community_id).await;
4341
4342 let original = crate::db::community::get_community_banlist(&cid_hex)?;
4343 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4344 let community0 = crate::db::community::load_community_v2(community_id)?
4345 .ok_or_else(|| "v2 community not found".to_string())?;
4346 let owner = community0.owner()?;
4347 let my_pk = me_pk()?;
4348 let my_hex = my_pk.to_hex();
4349 let owner_hex = owner.to_hex();
4350
4351 let mut list = original.clone();
4354 let mut pk_of: std::collections::HashMap<String, PublicKey> = std::collections::HashMap::new();
4355 let mut verdicts: std::collections::HashMap<u64, Result<(), String>> = std::collections::HashMap::new();
4356 for intent in &batch {
4357 let hexes: Vec<String> = intent.targets.iter().map(|p| p.to_hex()).collect();
4358 for (pk, hex) in intent.targets.iter().zip(&hexes) {
4359 pk_of.insert(hex.clone(), *pk);
4360 }
4361 let verdict = (|| {
4362 if my_pk != owner {
4365 if original.contains(&my_hex) {
4366 return Err("you are banned from this community".to_string());
4367 }
4368 if !roster.is_authorized(&my_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN) {
4369 return Err("editing the banlist needs the BAN permission".to_string());
4370 }
4371 if intent.banned {
4372 for t in hexes.iter().filter(|t| !list.contains(*t)) {
4373 if !roster.can_act_on_member(&my_hex, Some(&owner_hex), t, crate::community::roles::Permissions::BAN) {
4374 return Err("you do not outrank a member this ban targets".to_string());
4375 }
4376 }
4377 }
4378 }
4379 let mut next = list.clone();
4380 next.retain(|h| !hexes.contains(h));
4381 if intent.banned {
4382 next.extend(hexes.iter().cloned());
4383 }
4384 super::roles::validate_banlist(&next)?;
4385 list = next;
4386 Ok(())
4387 })();
4388 verdicts.insert(intent.id, verdict);
4389 }
4390
4391 let changed = {
4392 let mut a = original.clone();
4393 let mut b = list.clone();
4394 a.sort();
4395 b.sort();
4396 a != b
4397 };
4398 let newly_banned: Vec<PublicKey> = list
4399 .iter()
4400 .filter(|h| !original.contains(*h))
4401 .filter_map(|h| pk_of.get(h).copied())
4402 .collect();
4403
4404 let unit_result: Result<(), String> = async {
4406 if !changed {
4407 return Ok(());
4408 }
4409 let (community, stripped) = {
4413 let lock = super::realtime::follow_lock(community_id);
4414 let _rotation = lock.lock().await;
4415 let community = crate::db::community::load_community_v2(community_id)?
4416 .ok_or_else(|| "v2 community not found".to_string())?;
4417 set_banlist(transport, &community, &list).await?;
4418 let mut stripped: Vec<(PublicKey, Vec<String>)> = Vec::new();
4419 for pk in &newly_banned {
4423 let hex = pk.to_hex();
4424 let roles: Vec<String> = roster.roles_of(&hex).map(|r| r.role_id.clone()).collect();
4425 let has_grant = roster.grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
4428 if has_grant {
4429 let can_strip = my_pk == owner
4435 || roster.can_act_on_member(&my_hex, Some(&owner_hex), &hex, crate::community::roles::Permissions::MANAGE_ROLES);
4436 if can_strip {
4437 grant_roles(transport, &community, pk, vec![]).await?;
4438 } else {
4439 crate::log_warn!("[Ban] grant strip skipped (no MANAGE_ROLES over the target); the banlist still silences");
4440 }
4441 }
4442 stripped.push((*pk, roles));
4443 }
4444 (community, stripped)
4445 };
4446 if !newly_banned.is_empty() {
4447 if community_is_public(transport, &community).await {
4453 crate::log_info!("[Ban] public community — banlist + grant strip, no refound (CORD-05 §5)");
4454 match sever_banned_private_reads(transport, &community, &stripped).await {
4457 Ok(0) => {}
4458 Ok(n) => crate::log_info!("[Ban] rotated {n} private channel(s) the banned member(s) could read"),
4459 Err(e) => crate::log_warn!("[Ban] private-channel read severance incomplete: {e}"),
4460 }
4461 } else if let Err(e) = refound_community(transport, &community, &newly_banned).await {
4462 crate::log_warn!("[Ban] refound deferred (banlist + grant strip landed): {e}");
4471 }
4472 }
4473 Ok(())
4474 }
4475 .await;
4476 converge_authority(transport, community_id).await;
4477
4478 let mut my_verdict = Err("leader intent missing from its own batch".to_string());
4480 for intent in batch {
4481 let final_verdict = verdicts
4482 .remove(&intent.id)
4483 .unwrap_or_else(|| Err("intent was never judged".to_string()))
4484 .and_then(|()| unit_result.clone());
4485 if intent.id == leader_id {
4486 my_verdict = final_verdict;
4487 } else {
4488 let _ = intent.done.send(final_verdict);
4489 }
4490 }
4491 my_verdict
4492}
4493
4494pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
4497 ensure_folded_permission(community, &me_pk()?, crate::community::roles::Permissions::MANAGE_METADATA, "editing the community metadata")?;
4498 control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
4499 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
4500 publish_control_edition(transport, community, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
4501}
4502
4503pub async fn persist_community_image(
4508 id: &crate::community::CommunityId,
4509 img: control::ImageRef,
4510 is_banner: bool,
4511) -> Option<CommunityV2> {
4512 let lock = super::realtime::follow_lock(id);
4513 let _guard = lock.lock().await;
4514 let mut fresh = crate::db::community::load_community_v2(id).ok()??;
4515 if is_banner {
4516 fresh.banner = Some(img);
4517 } else {
4518 fresh.icon = Some(img);
4519 }
4520 crate::db::community::save_community_v2(&fresh).ok()?;
4521 Some(fresh)
4522}
4523
4524pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
4527 let my_pk = me_pk()?;
4528 ensure_channel_manager(community, &my_pk)?;
4529 let old_name = community.channel(channel_id).map(|c| c.name.clone());
4530 if meta.private {
4535 if let Some(held) = community.channel(channel_id) {
4536 if !held.private {
4537 return Err("converting a public channel to private is not supported yet".to_string());
4538 }
4539 }
4540 }
4541 control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
4542 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
4543 publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
4544 if let Ok(Some(mut held)) = crate::db::community::load_community_v2(community.id()) {
4548 if let Some(ch) = held.channels.iter_mut().find(|c| c.id.0 == channel_id.0) {
4549 ch.name = meta.name.clone();
4550 ch.private = meta.private;
4551 ch.voice = meta.voice;
4552 ch.meta_custom = meta.custom.clone();
4553 ch.meta_extra = meta.extra.clone();
4554 crate::db::community::save_community_v2(&held)?;
4555 }
4556 }
4557 if meta.private {
4559 if let Some(old) = old_name.filter(|o| *o != meta.name) {
4560 rename_channel_access_role(transport, community, channel_id, &old, &meta.name).await;
4561 }
4562 }
4563 Ok(())
4564}
4565
4566async fn rename_channel_access_role<T: Transport + ?Sized>(
4573 transport: &T,
4574 community: &CommunityV2,
4575 channel_id: &ChannelId,
4576 old_name: &str,
4577 new_name: &str,
4578) {
4579 crate::db::scoped(async move {
4580 let (Ok(my_pk), Ok(owner)) = (me_pk(), community.owner()) else {
4581 return;
4582 };
4583 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4584 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4585 let mut roster = fetch_authority(transport, community).await.roles;
4588 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4589 for r in cached.roles {
4590 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4591 roster.roles.push(r);
4592 }
4593 }
4594 let (me_hex, owner_hex) = (my_pk.to_hex(), owner.to_hex());
4598 if !roster.is_authorized_in(&me_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
4599 return;
4600 }
4601 let Some(mut role) = roster
4604 .channel_roles(&chan_hex)
4605 .into_iter()
4606 .find(|r| r.permissions == crate::community::roles::Permissions::empty() && r.name == old_name)
4607 .cloned()
4608 else {
4609 return;
4610 };
4611 if !roster.can_act_on_position(&me_hex, Some(&owner_hex), role.position, crate::community::roles::Permissions::MANAGE_ROLES) {
4612 return;
4613 }
4614 role.name = new_name.to_string();
4615 if let Err(e) = set_role(transport, community, &role).await {
4616 crate::log_warn!("v2: channel renamed but its access role did not follow: {e}");
4617 return;
4618 }
4619 merge_local_roster(&cid_hex, Some(&role), None);
4620 })
4621 .await
4622}
4623
4624fn ensure_folded_permission(
4631 community: &CommunityV2,
4632 me: &PublicKey,
4633 needed: u64,
4634 verb: &str,
4635) -> Result<(), String> {
4636 let owner = community.owner()?;
4637 if *me == owner {
4638 return Ok(());
4639 }
4640 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4641 let me_hex = me.to_hex();
4642 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
4643 return Err("you are banned from this community".to_string());
4644 }
4645 let roster = crate::db::community::get_community_roles(&cid_hex)?;
4646 if roster.is_authorized(&me_hex, Some(&owner.to_hex()), needed) {
4647 Ok(())
4648 } else {
4649 Err(format!("{verb} needs the {} permission", permission_label(needed)))
4650 }
4651}
4652
4653fn permission_label(bits: u64) -> &'static str {
4654 use crate::community::roles::Permissions;
4655 match bits {
4656 Permissions::MANAGE_ROLES => "MANAGE_ROLES",
4657 Permissions::MANAGE_CHANNELS => "MANAGE_CHANNELS",
4658 Permissions::MANAGE_METADATA => "MANAGE_METADATA",
4659 Permissions::BAN => "BAN",
4660 Permissions::CREATE_INVITE => "CREATE_INVITE",
4661 _ => "required",
4662 }
4663}
4664
4665fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
4667 ensure_folded_permission(community, me, crate::community::roles::Permissions::MANAGE_CHANNELS, "managing channels here")
4668}
4669
4670pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
4676 let channel_id = ChannelId(super::super::random_32());
4677 create_public_channel_with_id(transport, community, name, channel_id).await?;
4678 Ok(channel_id)
4679}
4680
4681pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
4685 crate::db::scoped(async move {
4686 let lock = super::realtime::follow_lock(community.id());
4690 let _guard = lock.lock().await;
4691 let my_pk = me_pk()?;
4692 ensure_channel_manager(community, &my_pk)?;
4693 assert_channel_id_free(&channel_id, community.id())?;
4694 let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
4695 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
4696 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
4697 publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
4698 let mut updated = community.clone();
4700 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() });
4701 crate::db::community::save_community_v2(&updated)?;
4702 Ok(())
4703 })
4704 .await
4705}
4706
4707fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
4712 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4713 if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
4714 let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
4715 let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
4716 if existing != mine
4717 && matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
4718 {
4719 return Err("channel id is already live in another v2 community".to_string());
4720 }
4721 }
4722 Ok(())
4723}
4724
4725pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
4737 let channel_id = ChannelId(super::super::random_32());
4738 create_private_channel_with_id(transport, community, name, channel_id).await?;
4739 Ok(channel_id)
4740}
4741
4742pub fn channel_access_role(channel_id: &ChannelId, name: &str) -> crate::community::roles::Role {
4747 use crate::community::roles::{Permissions, Role, RoleScope};
4748 Role {
4749 role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
4750 name: name.to_string(),
4751 position: u32::MAX - 1,
4752 permissions: Permissions::empty(),
4753 scope: RoleScope::Channel(crate::simd::hex::bytes_to_hex_32(&channel_id.0)),
4754 color: 0,
4755 }
4756}
4757
4758pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
4761 crate::db::scoped(async move {
4762 let lock = super::realtime::follow_lock(community.id());
4766 let _guard = lock.lock().await;
4767 let signer = crate::signer::active_signer()?;
4768 let my_pk = me_pk()?;
4769 ensure_channel_manager(community, &my_pk)?;
4770 assert_channel_id_free(&channel_id, community.id())?;
4771 let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
4772 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
4773 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
4774
4775 let channel_key = super::super::random_32();
4776 let epoch = Epoch(1);
4777
4778 let access_role = channel_access_role(&channel_id, name);
4781 let access_role_ids = vec![access_role.role_id.clone()];
4782
4783 let owner = community.owner()?;
4787 let mut recipients = vec![my_pk];
4788 if owner != my_pk {
4789 recipients.push(owner);
4790 }
4791 let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
4792 let mut blobs = Vec::with_capacity(recipients.len());
4793 for r in &recipients {
4794 blobs.push(
4795 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
4796 .await
4797 .map_err(|e| e.to_string())?,
4798 );
4799 }
4800 let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
4801 let at_secs = now_ms() / 1000;
4802 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())
4803 .await
4804 .map_err(|e| e.to_string())?;
4805 for c in &chunks {
4806 transport.publish_durable(c, &community.relays).await?;
4807 }
4808 publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
4809 set_role(transport, community, &access_role).await?;
4814 let mut roster_with_access = crate::db::community::get_community_roles(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap_or_default();
4818 if roster_with_access.role(&access_role.role_id).is_none() {
4819 roster_with_access.roles.push(access_role.clone());
4820 }
4821 grant_roles_with_roster(transport, community, &my_pk, access_role_ids.clone(), &roster_with_access).await?;
4822 merge_local_roster(
4825 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
4826 Some(&access_role),
4827 Some(&crate::community::roles::MemberGrant { member: my_pk.to_hex(), role_ids: access_role_ids }),
4828 );
4829 if crate::db::community::community_protocol(community.id())?.is_none() {
4831 return Err("community removed during channel create".to_string());
4832 }
4833 let mut updated = community.clone();
4834 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() });
4835 crate::db::community::save_community_v2(&updated)?;
4836 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4839 crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
4840 let _ = refresh_public_links(transport, &updated).await;
4843 Ok(())
4844 })
4845 .await
4846}
4847
4848#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4853pub enum VendVerdict {
4854 Accept,
4856 Park(&'static str),
4860 Refuse(&'static str),
4862}
4863
4864pub fn judge_channel_key_vend(
4876 community: &CommunityV2,
4877 roster: &crate::community::roles::CommunityRoles,
4878 channel_id: &ChannelId,
4879 epoch: Epoch,
4880 sender_hex: &str,
4881) -> VendVerdict {
4882 let me = match me_pk() {
4883 Ok(pk) => pk.to_hex(),
4884 Err(_) => return VendVerdict::Park("no active identity"),
4885 };
4886 let owner_hex = match community.owner() {
4887 Ok(o) => o.to_hex(),
4888 Err(_) => return VendVerdict::Refuse("community has no resolvable owner"),
4889 };
4890 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4891
4892 let Some(ch) = community.channel(channel_id) else {
4895 return VendVerdict::Park("channel not in our fold yet");
4896 };
4897 if !ch.private {
4898 return VendVerdict::Refuse("vend names a channel our fold says is public");
4901 }
4902
4903 if ch.key.is_some() && epoch.0 <= ch.epoch.0 {
4911 return VendVerdict::Refuse("superseded: we already hold this epoch or newer");
4912 }
4913 if epoch.0 > ch.epoch.0.saturating_add(MAX_VEND_EPOCH_LEAD) {
4914 return VendVerdict::Refuse("vend epoch is implausibly far ahead of the channel head");
4915 }
4916
4917 if !roster.is_entitled(Some(&owner_hex), &me, &chan_hex, &[], &[]) {
4921 return VendVerdict::Park("our grant for this channel has not folded yet");
4922 }
4923
4924 if sender_hex != owner_hex && !roster.is_entitled(Some(&owner_hex), sender_hex, &chan_hex, &[], &[]) {
4927 return VendVerdict::Park("vendor's entitlement has not folded yet");
4928 }
4929
4930 VendVerdict::Accept
4931}
4932
4933const PARKED_VEND_TTL_SECS: u64 = 30 * 24 * 3600;
4936
4937const MAX_VEND_EPOCH_LEAD: u64 = 1024;
4941
4942pub fn absorb_parked_channel_keys(community: &CommunityV2, session: &std::sync::Arc<crate::db::Session>) -> Vec<ChannelId> {
4948 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4949 let parked = match crate::db::community::get_pending_channel_keys(&cid_hex) {
4950 Ok(p) if !p.is_empty() => p,
4951 _ => return Vec::new(),
4952 };
4953 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4954 let mut adopted = Vec::new();
4955 let now = now_ms() / 1000;
4956 for p in parked {
4957 if adopted.iter().any(|c: &ChannelId| crate::simd::hex::bytes_to_hex_32(&c.0) == p.channel_id) {
4961 let _ = crate::db::community::drop_pending_channel_key(p.id);
4962 continue;
4963 }
4964 if now.saturating_sub(p.received_at.max(0) as u64) > PARKED_VEND_TTL_SECS {
4970 let _ = crate::db::community::drop_pending_channel_key(p.id);
4971 continue;
4972 }
4973 let Some(id_bytes) = crate::simd::hex::hex_to_bytes_32_checked(&p.channel_id) else {
4974 let _ = crate::db::community::drop_pending_channel_key(p.id);
4975 continue;
4976 };
4977 let channel_id = ChannelId(id_bytes);
4978 match judge_channel_key_vend(community, &roster, &channel_id, Epoch(p.epoch), &p.sender) {
4979 VendVerdict::Accept => {
4980 if !session.is_live() {
4981 return adopted;
4982 }
4983 let keyless = community.channel(&channel_id).is_some_and(|c| c.key.is_none());
4988 let seated = if keyless {
4989 crate::db::community::seat_channel_key(&cid_hex, &p.channel_id, p.epoch, &p.key)
4990 } else {
4991 crate::db::community::advance_channel_epoch(&cid_hex, &p.channel_id, p.epoch, &p.key).map(|_| ())
4992 };
4993 if let Err(e) = seated {
4994 crate::log_warn!("v2: adopting a vended channel key failed: {e}");
4995 continue;
4996 }
4997 let _ = crate::db::community::drop_pending_channel_keys_for(&cid_hex, &p.channel_id);
4999 adopted.push(channel_id);
5000 }
5001 VendVerdict::Refuse(why) => {
5002 crate::log_warn!("v2: refused a vended channel key for {}: {why}", p.channel_id);
5003 let _ = crate::db::community::drop_pending_channel_key(p.id);
5005 }
5006 VendVerdict::Park(_) => {}
5008 }
5009 }
5010 adopted
5011}
5012
5013pub async fn grant_channel_access<T: Transport + ?Sized>(
5023 transport: &T,
5024 community: &CommunityV2,
5025 channel_id: &ChannelId,
5026 member: &PublicKey,
5027) -> Result<(), String> {
5028 crate::db::scoped(async move {
5029 let my_pk = me_pk()?;
5030 let ch = community.channel(channel_id).ok_or("unknown channel")?;
5031 if !ch.private {
5032 return Err("channel is public — every member already reads it".to_string());
5033 }
5034 if ch.key.is_none() {
5035 return Err("we hold no key for this channel, so we cannot vend it".to_string());
5036 }
5037 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5038 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5039 let owner_hex = community.owner()?.to_hex();
5040 let view = fetch_authority(transport, community).await;
5046 let mut roster = view.roles.clone();
5047 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5048 for r in cached.roles {
5049 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
5050 roster.roles.push(r);
5051 }
5052 }
5053 for g in cached.grants {
5054 if !roster.grants.iter().any(|x| x.member == g.member) {
5055 roster.grants.push(g);
5056 }
5057 }
5058 let my_hex = my_pk.to_hex();
5059 let member_hex = member.to_hex();
5060 if !roster.is_authorized_in(&my_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
5063 return Err("not authorized to manage this channel's access".to_string());
5064 }
5065 if member_hex != owner_hex {
5070 if !roster.can_act_on_member(&my_hex, Some(&owner_hex), &member_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
5071 return Err("you do not outrank this member".to_string());
5072 }
5073 require_grant_head(community, &view, &member_hex)?;
5077 }
5078 let role_id = roster
5083 .channel_roles(&chan_hex)
5084 .into_iter()
5085 .find(|r| r.permissions == crate::community::roles::Permissions::empty())
5086 .map(|r| r.role_id.clone())
5087 .ok_or("channel has no permission-less access role to grant")?;
5088
5089 let mut role_ids: Vec<String> = roster.roles_of(&member_hex).map(|r| r.role_id.clone()).collect();
5090 if !role_ids.contains(&role_id) {
5091 role_ids.push(role_id.clone());
5092 }
5093 grant_roles_with_roster(transport, community, member, role_ids.clone(), &roster).await?;
5096 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids }));
5097 let bundle = bundle_of_with_overlay(
5099 community,
5100 BundleAudience::Member(*member),
5101 Some(my_pk),
5102 None,
5103 None,
5104 std::slice::from_ref(&role_id),
5105 &[],
5106 );
5107 let signer = crate::signer::active_signer()?;
5108 let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
5109 transport.publish(&wrap, &community.relays).await?;
5110 Ok(())
5111 })
5112 .await
5113}
5114
5115pub async fn revoke_channel_access<T: Transport + ?Sized>(
5121 transport: &T,
5122 community: &CommunityV2,
5123 channel_id: &ChannelId,
5124 member: &PublicKey,
5125) -> Result<(), String> {
5126 crate::db::scoped(async move {
5127 let my_pk = me_pk()?;
5128 let ch = community.channel(channel_id).ok_or("unknown channel")?;
5129 if !ch.private {
5130 return Err("channel is public — there is no access to revoke".to_string());
5131 }
5132 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5133 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5134 let owner_hex = community.owner()?.to_hex();
5135 let view = fetch_authority(transport, community).await;
5138 let mut roster = view.roles.clone();
5139 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5140 for r in cached.roles {
5141 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
5142 roster.roles.push(r);
5143 }
5144 }
5145 for g in cached.grants {
5146 if !roster.grants.iter().any(|x| x.member == g.member) {
5147 roster.grants.push(g);
5148 }
5149 }
5150 let my_hex = my_pk.to_hex();
5151 let member_hex = member.to_hex();
5152 if !roster.is_authorized_in(&my_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
5153 return Err("not authorized to manage this channel's access".to_string());
5154 }
5155 if *member == community.owner()? {
5156 return Err("the owner is supreme and cannot be removed".to_string());
5157 }
5158 if !roster.can_act_on_member(&my_hex, Some(&owner_hex), &member_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
5162 return Err("you do not outrank this member".to_string());
5163 }
5164 require_grant_head(community, &view, &member_hex)?;
5166 let access_ids = roster.channel_role_ids(&chan_hex);
5167 if access_ids.is_empty() {
5171 return Err("this channel's access role has not folded yet — retry once the control plane serves it".to_string());
5172 }
5173 let remaining: Vec<String> = roster
5174 .roles_of(&member_hex)
5175 .map(|r| r.role_id.clone())
5176 .filter(|id| !access_ids.contains(id))
5177 .collect();
5178 grant_roles_with_roster(transport, community, member, remaining.clone(), &roster).await?;
5179 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids: remaining }));
5180 rekey_channel_excluding(transport, community, channel_id, &roster, &access_ids, std::slice::from_ref(member)).await
5184 })
5185 .await
5186}
5187
5188async fn rekey_channel_excluding<T: Transport + ?Sized>(
5199 transport: &T,
5200 community: &CommunityV2,
5201 channel_id: &ChannelId,
5202 roster: &crate::community::roles::CommunityRoles,
5203 access_ids: &[String],
5204 removed: &[PublicKey],
5205) -> Result<(), String> {
5206 crate::db::scoped(async move {
5207 let lock = super::realtime::follow_lock(community.id());
5209 let _guard = lock.lock().await;
5210 let signer = crate::signer::active_signer()?;
5211 let my_pk = me_pk()?;
5212 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5213 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5214 let ch = community.channel(channel_id).ok_or("unknown channel")?.clone();
5215 let old_key = ch.key.ok_or("we hold no key for this channel, so we cannot rotate it")?;
5216 let new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
5217 let owner = community.owner()?;
5218 let owner_hex = owner.to_hex();
5219
5220 let removed_hexes: std::collections::HashSet<String> = removed.iter().map(|p| p.to_hex()).collect();
5224 let mut recipients: Vec<PublicKey> = vec![my_pk];
5225 if owner != my_pk {
5226 recipients.push(owner);
5227 }
5228 for g in &roster.grants {
5229 if removed_hexes.contains(&g.member) || g.member == owner_hex {
5230 continue;
5231 }
5232 if !g.role_ids.iter().any(|id| access_ids.contains(id)) {
5233 continue;
5234 }
5235 if let Ok(pk) = PublicKey::parse(&g.member) {
5236 if !recipients.contains(&pk) {
5237 recipients.push(pk);
5238 }
5239 }
5240 }
5241 let new_key = mint_or_reuse_rotation_key(&cid_hex, &chan_hex, new_epoch.0)?;
5244 let prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
5245 let mut blobs = Vec::with_capacity(recipients.len());
5246 for r in &recipients {
5247 blobs.push(
5248 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(*channel_id), new_epoch, &new_key)
5249 .await
5250 .map_err(|e| e.to_string())?,
5251 );
5252 }
5253 let group = channel_rekey_group_key(&community.community_root, channel_id, new_epoch);
5254 let at_secs = now_ms() / 1000;
5255 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())
5256 .await
5257 .map_err(|e| e.to_string())?;
5258 for c in &chunks {
5259 transport.publish_durable(c, &community.relays).await?;
5260 }
5261 if crate::db::community::community_protocol(community.id())?.is_none() {
5262 return Err("community removed during channel rekey".to_string());
5263 }
5264 crate::db::community::advance_channel_epoch(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
5266 crate::db::community::store_epoch_key(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
5267
5268 {
5275 let mut rotated = community.clone();
5276 if let Some(c) = rotated.channels.iter_mut().find(|c| c.id == *channel_id) {
5277 c.key = Some(new_key);
5278 c.epoch = new_epoch;
5279 }
5280 match read_channel_pins(&rotated, channel_id) {
5281 Ok(read) if !read.sealed && !read.pins.is_empty() => {
5282 let entries: Vec<super::pins::PinEntry> =
5283 read.pins.iter().map(|p| p.entry.clone()).collect();
5284 if let Some(ch2) = rotated.channel(channel_id).cloned() {
5285 if let Err(e) = publish_pin_list(transport, &rotated, &ch2, &entries).await {
5286 crate::log_warn!("[pins] rotation reseal failed (a curator's next edit heals): {e}");
5287 } else {
5288 crate::log_info!("[pins] resealed {} pin(s) under epoch {}", entries.len(), new_epoch.0);
5289 }
5290 }
5291 }
5292 Ok(read) if read.sealed => {
5293 crate::log_warn!("[pins] rotating a channel whose pin list we cannot read; reseal skipped");
5294 }
5295 _ => {}
5296 }
5297 }
5298 Ok(())
5299 })
5300 .await
5301}
5302
5303pub async fn sever_banned_private_reads<T: Transport + ?Sized>(
5323 transport: &T,
5324 community: &CommunityV2,
5325 targets: &[(PublicKey, Vec<String>)],
5326) -> Result<usize, String> {
5327 crate::db::scoped(async move {
5328 let my_pk = me_pk()?;
5329 ensure_folded_permission(community, &my_pk, crate::community::roles::Permissions::MANAGE_CHANNELS, "severing a banned member's channel reads")?;
5330 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5331 let mut roster = fetch_authority(transport, community).await.roles;
5335 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5336 for r in cached.roles {
5337 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
5338 roster.roles.push(r);
5339 }
5340 }
5341 for g in cached.grants {
5342 if !roster.grants.iter().any(|x| x.member == g.member) {
5343 roster.grants.push(g);
5344 }
5345 }
5346 let owner_hex = community.owner().ok().map(|o| o.to_hex());
5347 let mut rotated = 0usize;
5348 let mut failures: Vec<String> = Vec::new();
5349 for ch in &community.channels {
5350 if !ch.private || ch.key.is_none() {
5351 continue;
5352 }
5353 let chan_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
5354 let excluded: Vec<PublicKey> = targets
5356 .iter()
5357 .filter(|(pk, stripped)| roster.is_entitled(owner_hex.as_deref(), &pk.to_hex(), &chan_hex, stripped, &[]))
5358 .map(|(pk, _)| *pk)
5359 .collect();
5360 if excluded.is_empty() {
5361 continue;
5362 }
5363 let access_ids = roster.channel_role_ids(&chan_hex);
5364 if access_ids.is_empty() {
5367 continue;
5368 }
5369 let held = match crate::db::community::load_community_v2(community.id()) {
5372 Ok(Some(c)) => c,
5373 _ => return Err("community gone during ban severance".to_string()),
5374 };
5375 match rekey_channel_excluding(transport, &held, &ch.id, &roster, &access_ids, &excluded).await {
5376 Ok(()) => rotated += 1,
5377 Err(e) => failures.push(format!("{}: {e}", &chan_hex[..12])),
5378 }
5379 }
5380 if failures.is_empty() {
5381 Ok(rotated)
5382 } else {
5383 Err(format!("{rotated} rotated; failed: {}", failures.join("; ")))
5384 }
5385 })
5386 .await
5387}
5388
5389pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
5392 crate::db::scoped(async move {
5393 let lock = super::realtime::follow_lock(community.id());
5395 let _guard = lock.lock().await;
5396 let my_pk = me_pk()?;
5397 ensure_channel_manager(community, &my_pk)?;
5398 let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
5402 name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
5403 });
5404 meta.deleted = Some(true);
5405 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
5406 publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
5407 let mut updated = community.clone();
5408 updated.channels.retain(|c| c.id.0 != channel_id.0);
5409 crate::db::community::save_community_v2(&updated)?;
5410 Ok(())
5411 })
5412 .await
5413}
5414
5415pub async fn follow_control<T: Transport + ?Sized>(
5439 transport: &T,
5440 community: &CommunityV2,
5441) -> Result<Option<CommunityV2>, String> {
5442 crate::db::scoped(async move {
5443 community.owner()?; let control = control::ControlPlane::of(community);
5445 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5446
5447 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
5453 .into_iter()
5454 .filter(|(_, f)| f.0 == community.root_epoch.0)
5455 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
5456 .collect();
5457
5458 let mut editions: Vec<ParsedEdition> = Vec::new();
5462 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
5463 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
5464 let mut oldest: Option<u64> = None;
5465 let mut until: Option<u64> = None;
5466 let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false, pins_persist: Vec::new() };
5467 let mut authority = AuthoritySet::owner_only();
5468 let mut truncated = true;
5474 for _ in 0..FOLLOW_MAX_PAGES {
5475 let query = Query {
5479 kinds: vec![stream::KIND_WRAP],
5480 authors: vec![control.pk_hex()],
5481 until,
5482 limit: Some(FOLLOW_PAGE),
5483 evidence: crate::community::transport::Evidence::Quorum,
5484 ..Default::default()
5485 };
5486 let wraps = transport.fetch(&query, &community.relays).await?;
5487 let mut fresh = 0usize;
5491 for w in &wraps {
5492 if !seen_wraps.insert(w.id) {
5493 continue;
5494 }
5495 fresh += 1;
5496 let at = w.created_at.as_secs();
5497 if oldest.is_none_or(|o| at < o) {
5498 oldest = Some(at);
5499 }
5500 if let Ok((ed, _)) = control.open(w) {
5503 if seen.insert(ed.inner_id) {
5504 editions.push(ed);
5505 }
5506 }
5507 }
5508 authority = fold_authority(community, &editions, &floors);
5511 fold = apply_control_fold(community, &editions, &floors, &authority);
5512 if !(fold.gapped || authority.gapped) {
5513 truncated = false; break;
5515 }
5516 if fresh == 0 {
5517 truncated = wraps.len() >= FOLLOW_PAGE;
5522 break;
5523 }
5524 until = oldest;
5525 }
5526
5527 if crate::db::community::community_protocol(community.id())?.is_none() {
5532 return Ok(None);
5533 }
5534 for h in fold.heads.iter().chain(authority.heads.iter()) {
5540 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)?;
5541 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)?;
5542 }
5543 let adopted_control_root = adopt_my_control_wrap(community, &editions).await;
5550 let mut authority_changed = false;
5555 let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
5559 if let Some((banned, version)) = &authority.banlist_persist {
5560 let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5561 crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
5562 let mut after = banned.clone();
5563 before.sort();
5564 after.sort();
5565 authority_changed |= before != after;
5566 }
5567 let newest_roster_at: i64 = editions
5574 .iter()
5575 .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
5576 .map(|e| e.created_at as i64)
5577 .max()
5578 .unwrap_or(0);
5579 let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5585 let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
5586 let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
5587 && stored.grants.iter().all(|g| {
5588 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
5589 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
5590 !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
5591 })
5592 });
5593 if !truncated && !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
5598 authority_changed |= stored != authority.roles;
5599 crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
5600 }
5601 if !truncated && !authority.gapped && !fold.gapped {
5607 if let Ok(owner) = community.owner() {
5608 let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
5609 let live = flatten_link_sets(&sets);
5610 let mut before = crate::db::community::get_community_invite_registry(&cid_hex).unwrap_or_default();
5611 before.sort();
5612 if before != live {
5613 crate::db::community::set_community_invite_registry(&cid_hex, &live)?;
5614 authority_changed = true;
5615 }
5616 crate::db::community::replace_invite_link_sets(&cid_hex, &sets)?;
5619 }
5620 }
5621 for (channel_hex, content, version, author_npub, created_at) in &fold.pins_persist {
5625 match crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64) {
5626 Ok(true) => {
5627 crate::log_info!("[pins] fold adopted v{} for channel {}", version, &channel_hex[..12]);
5628 crate::emit_event(
5629 "community_pins_updated",
5630 &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
5631 );
5632 note_pins_modified(channel_hex, *version, author_npub, *created_at).await;
5633 }
5634 Ok(false) => {}
5635 Err(e) => crate::log_warn!("[pins] fold persist failed: {e}"),
5636 }
5637 }
5638 if authority_changed {
5643 crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
5644 }
5645 {
5649 static UPGRADED: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
5650 let first = UPGRADED
5651 .lock()
5652 .map(|mut set| set.get_or_insert_with(Default::default).insert(cid_hex.clone()))
5653 .unwrap_or(false);
5654 if first {
5655 let _ = upgrade_admin_role_pin_bit(transport, community).await;
5656 }
5657 }
5658 let mut updated = fold.updated;
5662 if let Some(cr) = adopted_control_root {
5663 let mut u = updated.take().unwrap_or_else(|| community.clone());
5664 u.control_root = Some(cr);
5665 updated = Some(u);
5666 }
5667 match updated {
5668 Some(u) => {
5669 crate::db::community::save_community_v2(&u)?;
5670 Ok(Some(u))
5671 }
5672 None => Ok(None),
5673 }
5674 })
5675 .await
5676}
5677
5678async fn adopt_my_control_wrap(community: &CommunityV2, editions: &[ParsedEdition]) -> Option<[u8; 32]> {
5689 use nostr_sdk::prelude::AsyncNip44;
5690 let held_pk = community.control_pk?;
5691 if community.control_root.is_some() {
5692 return None;
5693 }
5694 let my_pk = me_pk().ok()?;
5695 let signer = crate::signer::active_signer().ok()?;
5696 let my_eid = super::derive::grant_locator(community.id(), &my_pk.to_bytes());
5697 let mut candidates: Vec<&ParsedEdition> = editions
5698 .iter()
5699 .filter(|e| e.vsk == vsk::GRANT && e.entity_id == my_eid)
5700 .collect();
5701 candidates.sort_by(|a, b| b.version.cmp(&a.version));
5702 for ed in candidates {
5703 let Some(wrap_b64) = super::roles::parse_grant_control_wrap(&ed.content) else { continue };
5704 let Ok(plain_b64) = signer.nip44_decrypt_async(&ed.author, &wrap_b64).await else { continue };
5707 let Ok(plain) = base64_simd::STANDARD.decode_to_vec(plain_b64.as_bytes()) else { continue };
5708 let Ok((epoch, root)) = super::rekey::parse_control_wrap(&plain) else { continue };
5709 if epoch != community.root_epoch {
5710 continue;
5711 }
5712 if super::derive::control_signer_group_key(&root, community.id(), epoch).pk() != held_pk {
5713 continue;
5714 }
5715 crate::log_info!("[v2] adopted the staff write key from my Grant (epoch {})", epoch.0);
5716 return Some(root);
5717 }
5718 None
5719}
5720
5721const FOLLOW_MAX_PAGES: usize = 32;
5730const FOLLOW_PAGE: usize = 500;
5731const COMPACT_MAX_PAGES: usize = 512;
5736
5737#[derive(Clone)]
5739struct FoldedHead {
5740 entity_hex: String,
5741 version: u64,
5742 self_hash: [u8; 32],
5743 inner_id: [u8; 32],
5744}
5745
5746struct ControlFold {
5752 updated: Option<CommunityV2>,
5753 heads: Vec<FoldedHead>,
5754 gapped: bool,
5755 pins_persist: Vec<(String, String, u64, String, u64)>,
5759}
5760
5761type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
5763
5764fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
5785 use crate::community::roles::Permissions;
5786 use std::collections::BTreeMap;
5787
5788 let owner_hex = community.owner().ok().map(|o| o.to_hex());
5789
5790 let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
5791 for e in editions {
5792 groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
5793 }
5794
5795 let mut out = community.clone();
5796 let mut changed = false;
5797 let mut heads = Vec::new();
5798 let mut gapped = false;
5799 let mut pins_persist = Vec::new();
5800 let pins_by_eid: std::collections::HashMap<[u8; 32], String> = community
5805 .channels
5806 .iter()
5807 .map(|ch| (super::derive::pins_locator(community.id(), &ch.id), crate::simd::hex::bytes_to_hex_32(&ch.id.0)))
5808 .collect();
5809 for ((vsk_code, eid), group) in &groups {
5810 let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
5816 let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
5817 let pins_channel = (vsk_code == vsk::PINS).then(|| pins_by_eid.get(eid)).flatten();
5818 if !is_meta && !is_channel && pins_channel.is_none() {
5819 continue;
5820 }
5821 let required = if is_meta {
5826 Permissions::MANAGE_METADATA
5827 } else if is_channel {
5828 Permissions::MANAGE_CHANNELS
5829 } else {
5830 Permissions::PIN_MESSAGES
5831 };
5832 let authed: Vec<&ParsedEdition> = group
5833 .iter()
5834 .copied()
5835 .filter(|e| {
5836 let author = e.author.to_hex();
5837 !authority.banned.contains(&author)
5840 && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
5841 && citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
5845 })
5846 .collect();
5847 if authed.is_empty() {
5848 continue;
5849 }
5850 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
5851 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
5852 let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
5853 gapped |= entity_gapped;
5854 let Some(hi) = hi else { continue };
5855
5856 let head = authed[hi];
5857 heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
5858 if is_meta {
5859 if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
5860 changed |= apply_community_metadata(&mut out, meta);
5861 }
5862 } else if is_channel {
5863 if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
5864 changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
5868 }
5869 } else if let Some(channel_hex) = pins_channel {
5870 use nostr_sdk::prelude::ToBech32;
5874 let author_npub = head
5875 .author
5876 .to_bech32()
5877 .unwrap_or_else(|_| head.author.to_hex());
5878 pins_persist.push((channel_hex.clone(), head.content.clone(), head.version, author_npub, head.created_at));
5879 }
5880 }
5881 ControlFold { updated: changed.then_some(out), heads, gapped, pins_persist }
5882}
5883
5884fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
5891 let floor_v = floor.map(|f| f.0).unwrap_or(0);
5892 if floor_v == 0 {
5893 return (version::bootstrap_head(fold_eds, 0), false);
5894 }
5895 let floor_hash = floor.map(|f| &f.1);
5896 let held_inner = floor.and_then(|f| f.2);
5897 let result = version::fold(fold_eds, floor_v, floor_hash);
5898 if result.anchored {
5899 return (result.head, result.gap); }
5901 if result.head.is_none() && !result.gap {
5902 return (None, false); }
5904 let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
5908 let win_hash = match fork {
5909 Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
5910 _ => return (None, true), };
5912 let re = version::fold(fold_eds, floor_v, Some(&win_hash));
5913 if !re.anchored {
5914 return (None, true);
5915 }
5916 (re.head, re.gap)
5917}
5918
5919struct AuthoritySet {
5924 roles: crate::community::roles::CommunityRoles,
5925 banned: std::collections::BTreeSet<String>,
5926 heads: Vec<FoldedHead>,
5927 gapped: bool,
5928 banlist_persist: Option<(Vec<String>, u64)>,
5932 banned_at: std::collections::BTreeMap<String, u64>,
5936}
5937
5938impl AuthoritySet {
5939 fn owner_only() -> Self {
5942 AuthoritySet {
5943 roles: Default::default(),
5944 banned: Default::default(),
5945 heads: vec![],
5946 gapped: false,
5947 banlist_persist: None,
5948 banned_at: Default::default(),
5949 }
5950 }
5951}
5952
5953fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
5960 use crate::community::roles::Permissions;
5961 use std::collections::BTreeMap;
5962
5963 let cid = community.id();
5964 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
5965 let owner = community.owner().ok();
5966 let owner_hex = owner.map(|o| o.to_hex());
5967 let banlist_eid = super::derive::banlist_locator(cid);
5968 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
5969
5970 let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
5971 for e in editions {
5972 if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
5973 groups.entry(e.entity_id).or_default().push(e);
5974 }
5975 }
5976
5977 let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
5985 let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
5986 let mut gapped = false;
5987
5988 for (eid, group) in &groups {
5989 if *eid == banlist_eid {
5991 continue;
5992 }
5993 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
5994 let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
5995 let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
5996 gapped |= entity_gapped;
5997 let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
5998
5999 for p in group {
6000 if p.version < floor_v {
6002 continue;
6003 }
6004 let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
6005 match p.vsk.as_str() {
6006 vsk::ROLE => {
6007 if let Some(role) = super::roles::parse_role_content(&p.content) {
6009 if role.role_id == entity_hex && role.position != 0 {
6010 role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
6011 }
6012 }
6013 }
6014 vsk::GRANT => {
6015 if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
6016 if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
6017 if super::derive::grant_locator(cid, &member) == *eid {
6018 grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
6019 grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
6020 }
6021 }
6022 }
6023 }
6024 _ => {}
6025 }
6026 }
6027 }
6028 for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
6029 cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
6030 }
6031
6032 let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
6033 let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty);
6036
6037 let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
6048 let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
6053 let banlist_authored: Vec<&ParsedEdition> = groups
6054 .get(&banlist_eid)
6055 .map(|g| {
6056 g.iter()
6057 .copied()
6058 .filter(|e| {
6059 let ah = e.author.to_hex();
6060 !banned_authors.contains(ah.as_str())
6061 && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
6062 && citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
6063 })
6064 .collect()
6065 })
6066 .unwrap_or_default();
6067 let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
6073 for p in &banlist_authored {
6074 for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
6075 if owner_hex.as_deref() == Some(t.as_str()) {
6076 continue;
6077 }
6078 let slot = banned_at.entry(t).or_insert(0);
6079 *slot = (*slot).max(p.created_at);
6080 }
6081 }
6082 let mut banlist_persist: Option<(Vec<String>, u64)> = None;
6083 let mut banlist_head: Option<FoldedHead> = None;
6084 let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
6085 persisted_banned.into_iter().collect()
6086 } else {
6087 let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
6088 let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
6089 gapped |= g;
6090 match hi {
6091 Some(hi) => {
6092 let head = banlist_authored[hi];
6093 let ah = head.author.to_hex();
6094 let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
6095 .unwrap_or_default()
6096 .into_iter()
6097 .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
6098 .take(super::roles::MAX_BANLIST)
6099 .collect();
6100 banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
6101 banlist_persist = Some((list.clone(), head.version));
6102 list.into_iter().collect()
6103 }
6104 None => persisted_banned.into_iter().collect(),
6105 }
6106 };
6107
6108 let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned);
6112 if let Some(bh) = banlist_head {
6113 heads.push(bh);
6114 }
6115
6116 if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
6121 authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
6122 authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
6123 let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
6124 authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
6125 authorized.grants.retain(|g| !g.role_ids.is_empty());
6126 }
6127
6128 AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
6129}
6130
6131struct AuthorityCand {
6134 role: Option<crate::community::roles::Role>,
6135 grant: Option<crate::community::roles::MemberGrant>,
6136 author: PublicKey,
6137 head: FoldedHead,
6138 citation: Option<crate::community::edition::AuthorityCitation>,
6141}
6142
6143fn citation_ok_in_fold(
6152 cid: &crate::community::CommunityId,
6153 heads: &[FoldedHead],
6154 owner_hex: Option<&str>,
6155 author: &PublicKey,
6156 citation: Option<&crate::community::edition::AuthorityCitation>,
6157) -> bool {
6158 let actor_hex = author.to_hex();
6159 if owner_hex == Some(actor_hex.as_str()) {
6160 return true;
6161 }
6162 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
6163 let as_entity: Vec<crate::community::roster::EntityHead> = heads
6164 .iter()
6165 .map(|h| crate::community::roster::EntityHead {
6166 entity_hex: h.entity_hex.clone(),
6167 version: h.version,
6168 self_hash: h.self_hash,
6169 inner_id: h.inner_id,
6170 citation: None,
6171 })
6172 .collect();
6173 crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
6174}
6175
6176fn select_authorized(
6185 cid: &crate::community::CommunityId,
6186 role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
6187 grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
6188 owner_hex: Option<&str>,
6189 excluded: &std::collections::BTreeSet<String>,
6190) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
6191 use crate::community::roles::{CommunityRoles, Permissions};
6192 let mut accepted = CommunityRoles::default();
6193 let mut heads: Vec<FoldedHead> = Vec::new();
6194 let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
6199 for _ in 0..bound {
6200 let mut next = CommunityRoles::default();
6201 let mut next_heads: Vec<FoldedHead> = Vec::new();
6202
6203 for cands in role_cands.values() {
6204 let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
6217 let mut standing: Option<u32> = None;
6218 let mut i = cands.len();
6219 while i > 0 {
6220 let hi = i;
6221 let ver = cands[i - 1].head.version;
6222 while i > 0 && cands[i - 1].head.version == ver {
6223 i -= 1;
6224 }
6225 for c in cands[i..hi].iter().rev() {
6227 let Some(role) = &c.role else { continue };
6228 let ah = c.author.to_hex();
6229 if excluded.contains(&ah) || role.position == 0 {
6230 continue;
6231 }
6232 if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
6233 continue;
6234 }
6235 if let Some(prev) = standing {
6236 if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
6237 continue;
6238 }
6239 }
6240 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
6241 continue;
6242 }
6243 admissible.insert(c.head.self_hash);
6244 standing = Some(role.position);
6245 break;
6246 }
6247 }
6248 for c in cands {
6249 let Some(role) = &c.role else { continue };
6250 if !admissible.contains(&c.head.self_hash) {
6251 continue;
6252 }
6253 next.roles.push(role.clone());
6254 next_heads.push(c.head.clone());
6255 break; }
6257 }
6258 for cands in grant_cands.values() {
6259 for c in cands {
6260 let Some(grant) = &c.grant else { continue };
6261 let ah = c.author.to_hex();
6262 if excluded.contains(&ah) || excluded.contains(&grant.member) {
6263 continue;
6264 }
6265 let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
6268 let Some(positions) = positions else { continue };
6269 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
6270 continue;
6271 }
6272 if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
6273 && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
6274 {
6275 next_heads.push(c.head.clone());
6279 if !grant.role_ids.is_empty() {
6280 next.grants.push(grant.clone());
6281 }
6282 break;
6283 }
6284 }
6285 }
6286
6287 let converged = next.roles == accepted.roles && next.grants == accepted.grants;
6288 accepted = next;
6289 heads = next_heads;
6290 if converged {
6291 break;
6292 }
6293 }
6294 (accepted, heads)
6295}
6296
6297fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
6301 let mut changed = false;
6302 if out.name != meta.name {
6303 out.name = meta.name;
6304 changed = true;
6305 }
6306 if out.description != meta.description {
6307 out.description = meta.description;
6308 changed = true;
6309 }
6310 if out.icon != meta.icon {
6314 out.icon = meta.icon;
6315 changed = true;
6316 }
6317 if out.banner != meta.banner {
6318 out.banner = meta.banner;
6319 changed = true;
6320 }
6321 if out.meta_custom != meta.custom {
6324 out.meta_custom = meta.custom;
6325 changed = true;
6326 }
6327 if out.meta_extra != meta.extra {
6328 out.meta_extra = meta.extra;
6329 changed = true;
6330 }
6331 let relays = crate::community::cap_relays(meta.relays);
6339 if !relays.is_empty() && out.relays != relays {
6340 out.relays = relays;
6341 changed = true;
6342 }
6343 changed
6344}
6345
6346fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
6351 let deleted = meta.deleted.unwrap_or(false);
6352 if deleted {
6353 let before = out.channels.len();
6354 out.channels.retain(|c| c.id.0 != id.0);
6355 return out.channels.len() != before;
6356 }
6357 match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
6358 Some(existing) => {
6359 let mut changed = false;
6360 if existing.name != meta.name {
6361 existing.name = meta.name;
6362 changed = true;
6363 }
6364 if existing.voice != meta.voice {
6367 existing.voice = meta.voice;
6368 changed = true;
6369 }
6370 if existing.meta_custom != meta.custom {
6371 existing.meta_custom = meta.custom;
6372 changed = true;
6373 }
6374 if existing.meta_extra != meta.extra {
6375 existing.meta_extra = meta.extra;
6376 changed = true;
6377 }
6378 if !meta.private && (existing.private || existing.key.is_some()) {
6387 existing.private = false;
6388 existing.key = None;
6389 changed = true;
6390 }
6391 changed
6392 }
6393 None if !meta.private => {
6394 out.channels.push(ChannelV2 {
6397 id,
6398 name: meta.name,
6399 private: false,
6400 key: None,
6401 epoch: out.root_epoch,
6402 voice: meta.voice,
6403 meta_custom: meta.custom,
6404 meta_extra: meta.extra,
6405 });
6406 true
6407 }
6408 None => {
6409 out.channels.push(ChannelV2 {
6415 id,
6416 name: meta.name,
6417 private: true,
6418 key: None,
6419 epoch: Epoch(0),
6420 voice: meta.voice,
6421 meta_custom: meta.custom,
6422 meta_extra: meta.extra,
6423 });
6424 true
6425 }
6426 }
6427}
6428
6429pub struct RekeyFollow {
6433 pub updated: Option<CommunityV2>,
6436 pub self_removed: bool,
6439 pub dissolved: bool,
6442 pub wedged: bool,
6451}
6452
6453const MAX_ADDRESSING_ROOTS: usize = 8;
6460
6461pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
6470 let mut roots: Vec<[u8; 32]> = vec![cur_root];
6471 let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
6472 .unwrap_or_default();
6473 archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
6474 for (_, r) in archived {
6475 if !roots.contains(&r) {
6476 roots.push(r);
6477 }
6478 }
6479 roots.truncate(MAX_ADDRESSING_ROOTS);
6480 roots
6481}
6482
6483#[cfg(debug_assertions)]
6519pub async fn debug_explain_base_rekey<T: Transport + ?Sized>(
6520 transport: &T,
6521 community: &CommunityV2,
6522) -> Result<serde_json::Value, String> {
6523 let my_xonly = me_pk()?.to_bytes();
6524 let owner = community.owner()?;
6525 let owner_hex = owner.to_hex();
6526 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6527 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6528 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
6529 let held_epoch = community.root_epoch;
6530 let held_key = community.community_root;
6531 let next = Epoch(held_epoch.0.saturating_add(1));
6532 let group = base_rekey_group_key(&held_key, community.id(), next);
6533 let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
6534 let rotations = rekey::collect_rotations(&chunks);
6535
6536 let reports: Vec<serde_json::Value> = rotations
6537 .iter()
6538 .map(|r| {
6539 let rotator_is_owner = r.rotator == owner;
6540 let rotator_authorized = rotator_is_owner
6543 || (!banned.contains(&r.rotator.to_hex())
6544 && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
6545 let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
6546 let epoch_ok = r.new_epoch.0 == next.0;
6547 let complete = r.is_complete();
6548 let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
6549 let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
6550 let owner_kept = r.rotator == owner
6555 || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
6556 let verdict = if !rotator_authorized {
6558 "REJECTED: rotator holds no BAN authority in the folded roster"
6559 } else if !scope_ok {
6560 "REJECTED: scope is not Root"
6561 } else if !epoch_ok {
6562 "REJECTED: new_epoch != held+1"
6563 } else if !complete {
6564 "WAIT: rotation incomplete (missing chunk) — never concludes removal"
6565 } else if continuity != "Extends" {
6566 "REJECTED: continuity does not extend my held root (FORK/GAP)"
6567 } else if has_my_blob {
6568 "ADOPT: authorized + complete + continuous + my blob present"
6569 } else {
6570 "REMOVED: complete authorized rotation with no blob for me"
6571 };
6572 serde_json::json!({
6573 "rotator": r.rotator.to_hex(),
6574 "rotator_is_recorded_owner": rotator_is_owner,
6575 "rotator_authorized_ban": rotator_authorized,
6576 "scope_is_root": scope_ok,
6577 "new_epoch": r.new_epoch.0,
6578 "prev_epoch": r.prev_epoch.0,
6579 "declared_chunks": r.declared_chunks,
6580 "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
6581 "is_complete": complete,
6582 "continuity_vs_held_root": continuity,
6583 "my_blob_present": has_my_blob,
6584 "owner_kept": owner_kept,
6585 "blob_count": r.blobs.len(),
6586 "verdict": verdict,
6587 })
6588 })
6589 .collect();
6590
6591 Ok(serde_json::json!({
6592 "recorded_owner": owner.to_hex(),
6593 "held_root_epoch": held_epoch.0,
6594 "probing_next_epoch": next.0,
6595 "base_plane_pk": group.pk_hex(),
6596 "raw_chunks_parsed": chunks.len(),
6597 "rotations_found": rotations.len(),
6598 "rotations": reports,
6599 }))
6600}
6601
6602pub async fn follow_rekeys<T: Transport + ?Sized>(
6603 transport: &T,
6604 community: &CommunityV2,
6605 session: &std::sync::Arc<crate::db::Session>,
6606) -> Result<RekeyFollow, String> {
6607 crate::db::scoped(async move {
6608 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6611 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
6612 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true, wedged: false });
6613 }
6614 if is_dissolved(transport, community).await {
6620 if session.is_live() {
6621 let _ = crate::db::community::set_community_dissolved(&cid_hex);
6622 }
6623 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true, wedged: false });
6624 }
6625 let signer = crate::signer::active_signer()?;
6626 let my_pk = me_pk()?;
6627 let my_xonly = my_pk.to_bytes();
6628 let owner = community.owner()?;
6629 let owner_hex = owner.to_hex();
6630 let mut cur = community.clone();
6631 let mut changed = false;
6632 let mut wedged = false;
6634
6635 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6647 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
6648 let me_hex = my_pk.to_hex();
6649 let cited_ok = |rot: &rekey::Rotation| -> bool {
6658 citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
6659 };
6660 let channel_rotator_ok = |rotator: &PublicKey| -> bool {
6661 if *rotator == owner {
6662 return true;
6663 }
6664 let rh = rotator.to_hex();
6665 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
6666 };
6667 let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
6672 if *rotator == owner {
6673 return true;
6674 }
6675 let rh = rotator.to_hex();
6676 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
6677 };
6678 let base_rotator_ok = |rotator: &PublicKey| -> bool {
6684 if *rotator == owner {
6685 return true;
6686 }
6687 let rh = rotator.to_hex();
6688 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
6689 };
6690 let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
6696 if *rotator == owner {
6697 return true;
6698 }
6699 let rh = rotator.to_hex();
6700 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
6701 };
6702
6703 const MAX_STEPS: usize = 128;
6707 for _ in 0..MAX_STEPS {
6708 let mut advanced = false;
6709
6710 let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
6715
6716 let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
6719 for cid in channel_ids {
6720 let (held_key, held_epoch) = match cur.channel(&cid) {
6721 Some(ch) => (ch.key, ch.epoch),
6722 None => continue,
6723 };
6724 let next = Epoch(held_epoch.0.saturating_add(1));
6725 let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
6726 let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
6727 for (ri, root) in addressing_roots.iter().enumerate() {
6730 let group = channel_rekey_group_key(root, &cid, next);
6731 let chunks = match fetch_rekey_chunks(transport, &cur.relays, &group).await {
6732 Ok(c) => c,
6733 Err(e) => {
6734 crate::log_warn!(
6735 "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
6736 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
6737 );
6738 return Err(e);
6739 }
6740 };
6741 if chunks.is_empty() {
6742 continue;
6743 }
6744 crate::log_debug!(
6745 "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
6746 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
6747 );
6748 batches.push((chunks, held_key.map(|k| (held_epoch, k))));
6749 }
6750 match advance_scope(&batches, RekeyScope::Channel(cid), cur.id(), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
6757 Advance::Adopt { new_key, .. } => {
6758 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
6759 ch.key = Some(new_key);
6760 ch.epoch = next;
6761 }
6762 crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
6763 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) {
6768 crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
6769 }
6770 advanced = true;
6771 changed = true;
6772 }
6773 Advance::Removed => {
6774 match held_key {
6775 Some(_) => {
6777 cur.channels.retain(|c| c.id.0 != cid.0);
6778 }
6779 None => {
6784 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
6785 ch.epoch = next;
6786 }
6787 }
6788 }
6789 advanced = true;
6790 changed = true;
6791 }
6792 Advance::Stay => {}
6793 Advance::StayWedged => {
6794 wedged = true;
6798 }
6799 }
6800 }
6801
6802 {
6806 let held_epoch = cur.root_epoch;
6807 let held_key = cur.community_root;
6808 let next = Epoch(held_epoch.0.saturating_add(1));
6809 let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
6810 let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
6811 let batches = vec![(chunks, Some((held_epoch, held_key)))];
6812 let base_admissible = |r: &rekey::Rotation| -> bool {
6819 if r.rotator == owner {
6820 return true; }
6822 if !cited_ok(r) {
6826 return false;
6827 }
6828 let rotator_hex = r.rotator.to_hex();
6829 let has_blob = |xonly: &[u8; 32]| {
6830 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
6831 };
6832 if !has_blob(&owner.to_bytes()) {
6834 return false;
6835 }
6836 for g in &roster.grants {
6837 if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
6838 continue; }
6840 if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
6842 if let Ok(pk) = PublicKey::from_hex(&g.member) {
6843 if !has_blob(&pk.to_bytes()) {
6844 return false; }
6846 }
6847 }
6848 }
6849 true
6850 };
6851 match advance_scope(&batches, RekeyScope::Root, cur.id(), &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
6852 Advance::Adopt { new_key, control_pk, control_root } => {
6853 cur.community_root = new_key;
6854 cur.root_epoch = next;
6855 if cur.control_pk.is_some() && control_pk.is_none() {
6867 crate::log_warn!(
6868 "[v2:follow {}] rotation to epoch {} carried a LEGACY base blob — this community's control plane reverts to the member-writable address",
6869 &cid_hex[..8.min(cid_hex.len())], next.0
6870 );
6871 }
6872 cur.control_pk = control_pk.and_then(|pk| PublicKey::from_slice(&pk).ok());
6873 cur.control_root = control_root;
6874 if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
6878 crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
6879 }
6880 advanced = true;
6881 changed = true;
6882 }
6883 Advance::Removed => {
6884 return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false, wedged: false });
6885 }
6886 Advance::Stay => {}
6887 Advance::StayWedged => {
6888 wedged = true;
6892 }
6893 }
6894 }
6895
6896 if !advanced {
6897 break;
6898 }
6899 }
6900
6901 if wedged {
6902 crate::log_warn!(
6906 "[v2:follow {}] a complete rotation delivered a key blob this client cannot open — parked at epoch {} (never treated as a removal)",
6907 &cid_hex[..8.min(cid_hex.len())], cur.root_epoch.0
6908 );
6909 crate::emit_event(
6910 "community_rekey_wedged",
6911 &serde_json::json!({ "community_id": cid_hex, "held_epoch": cur.root_epoch.0 }),
6912 );
6913 }
6914 if !changed {
6915 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged });
6916 }
6917 if crate::db::community::community_protocol(community.id())?.is_none() {
6920 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged });
6921 }
6922 crate::db::community::save_community_v2(&cur)?;
6923 let _ = refresh_public_links(transport, &cur).await;
6931 Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false, wedged })
6932 })
6933 .await
6934}
6935
6936enum Advance {
6939 Adopt { new_key: [u8; 32], control_pk: Option<[u8; 32]>, control_root: Option<[u8; 32]> },
6946 Removed,
6948 Stay,
6950 StayWedged,
6954}
6955
6956async fn fetch_rekey_chunks<T: Transport + ?Sized>(
6958 transport: &T,
6959 relays: &[String],
6960 group: &GroupKey,
6961) -> Result<Vec<rekey::RekeyChunk>, String> {
6962 const REKEY_PAGE: usize = 200;
6970 const REKEY_MAX_PAGES: usize = 6;
6971 let mut out = Vec::new();
6972 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
6973 let mut until: Option<u64> = None;
6974 let mut oldest: Option<u64> = None;
6975 for _ in 0..REKEY_MAX_PAGES {
6976 let query = Query {
6977 kinds: vec![stream::KIND_WRAP],
6978 authors: vec![group.pk_hex()],
6979 until,
6980 limit: Some(REKEY_PAGE),
6981 ..Default::default()
6982 };
6983 let wraps = transport.fetch_plane(group.keys(), &query, relays).await?;
6988 let mut fresh = 0usize;
6989 for w in &wraps {
6990 if !seen.insert(w.id) {
6991 continue;
6992 }
6993 fresh += 1;
6994 let at = w.created_at.as_secs();
6995 if oldest.is_none_or(|o| at < o) {
6996 oldest = Some(at);
6997 }
6998 if let Ok(opened) = stream::open_wrap(w, group) {
6999 if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
7000 out.push(chunk);
7001 }
7002 }
7003 }
7004 if fresh == 0 || wraps.len() < REKEY_PAGE {
7007 break;
7008 }
7009 match oldest {
7010 Some(o) if o > 0 => until = Some(o),
7011 _ => break,
7012 }
7013 }
7014 Ok(out)
7015}
7016
7017async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
7031 batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
7032 scope: RekeyScope,
7033 community_id: &crate::community::CommunityId,
7034 rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
7035 rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
7036 admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
7037 signer: &S,
7038 my_xonly: &[u8; 32],
7039 next_epoch: Epoch,
7040) -> Advance {
7041 let mut winners: Vec<rekey::BaseKeyDelivery> = Vec::new();
7042 let mut saw_complete_candidate = false;
7043 let mut saw_outranking_candidate = false;
7044 let mut my_blob_unopenable = false;
7045 let keyed = batches.iter().any(|(_, held)| held.is_some());
7046 for (chunks, held) in batches {
7047 let rotations = rekey::collect_rotations(chunks);
7048 for r in &rotations {
7049 if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
7050 continue;
7051 }
7052 if let Some((held_epoch, held_key)) = held {
7053 if r.continuity(*held_epoch, held_key) != Continuity::Extends {
7054 continue;
7055 }
7056 }
7057 if !admissible(r) {
7063 continue;
7064 }
7065 saw_complete_candidate = true;
7066 saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
7067 if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
7068 let opened = match scope {
7069 RekeyScope::Root => rekey::open_base_blob(signer, &r.rotator, community_id, r.new_epoch, blob).await,
7071 RekeyScope::Channel(_) => rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob)
7072 .await
7073 .map(|k| rekey::BaseKeyDelivery { new_root: k, control_pk: None, control_root: None }),
7074 };
7075 match opened {
7076 Ok(d) => winners.push(d),
7077 Err(_) => my_blob_unopenable = true,
7083 }
7084 }
7085 }
7086 }
7087 if !winners.is_empty() {
7088 let keys: Vec<[u8; 32]> = winners.iter().map(|d| d.new_root).collect();
7097 let idx = rekey::lowest_key_winner(&keys).expect("winners is non-empty");
7098 let w = &winners[idx];
7099 return Advance::Adopt { new_key: w.new_root, control_pk: w.control_pk, control_root: w.control_root };
7100 }
7101 if saw_complete_candidate && !my_blob_unopenable && (!keyed || saw_outranking_candidate) {
7102 Advance::Removed
7103 } else if my_blob_unopenable {
7104 Advance::StayWedged
7105 } else {
7106 Advance::Stay
7107 }
7108}
7109
7110#[derive(Debug, serde::Serialize)]
7114pub struct ChannelPins {
7115 pub pins: Vec<super::pins::VerifiedPin>,
7117 pub sealed: bool,
7121 pub version: u64,
7123}
7124
7125fn channel_conv_key_at(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Option<[u8; 32]> {
7129 let ikm = channel_conv_ikm(community, ch, epoch).ok()?;
7130 if ch.private && ikm == community.community_root {
7133 return None;
7134 }
7135 let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
7136 group.conv_key().as_bytes().try_into().ok()
7137}
7138
7139pub fn read_channel_pins(community: &CommunityV2, channel_id: &ChannelId) -> Result<ChannelPins, String> {
7143 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
7144 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7145 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
7146 let Some((content, version)) = crate::db::community::get_community_pins(&cid_hex, &ch_hex)? else {
7147 return Ok(ChannelPins { pins: Vec::new(), sealed: false, version: 0 });
7148 };
7149 let read = super::pins::read_pin_list(&content, |epoch| channel_conv_key_at(community, ch, epoch));
7150 let pins = read
7151 .entries
7152 .iter()
7153 .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
7154 .collect();
7155 Ok(ChannelPins { pins, sealed: read.sealed, version: version.max(0) as u64 })
7156}
7157
7158async fn publish_pin_list<T: Transport + ?Sized>(
7162 transport: &T,
7163 community: &CommunityV2,
7164 ch: &ChannelV2,
7165 entries: &[super::pins::PinEntry],
7166) -> Result<(), String> {
7167 crate::db::scoped(async move {
7168 let content = if ch.private {
7169 let key = ch.key.ok_or("this private channel's key has not arrived yet")?;
7170 let group = channel_group_key(&key, &ch.id, ch.epoch);
7171 super::pins::serialize_sealed_pin_list(entries, group.conv_key(), ch.epoch.0)?
7172 } else {
7173 super::pins::serialize_public_pin_list(entries)?
7174 };
7175 let eid = super::derive::pins_locator(community.id(), &ch.id);
7176 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7177 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
7178 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
7179 let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
7181 Some((v, _)) => v + 1,
7182 None => 1,
7183 };
7184 crate::log_info!("[pins] publishing v{} with {} entries for channel {}", version, entries.len(), &ch_hex[..12]);
7185 publish_control_edition(transport, community, vsk::PINS, &eid, &content).await?;
7186 let _ = crate::db::community::set_community_pins(&cid_hex, &ch_hex, &content, version as i64);
7187 crate::emit_event(
7188 "community_pins_updated",
7189 &serde_json::json!({ "community_id": cid_hex, "channel_id": ch_hex }),
7190 );
7191 if let Ok(me) = me_pk() {
7192 use nostr_sdk::prelude::ToBech32;
7193 let me_npub = me.to_bech32().unwrap_or_else(|_| me.to_hex());
7194 note_pins_modified(&ch_hex, version, &me_npub, now_ms() / 1000).await;
7195 }
7196 Ok(())
7197 })
7198 .await
7199}
7200
7201async fn note_pins_modified(channel_hex: &str, version: u64, actor_npub: &str, at_secs: u64) {
7206 let event_id = format!("pins-mod-{}-v{}", &channel_hex[..16], version);
7207 let inserted = crate::db::events::save_system_event_at(
7208 &event_id,
7209 channel_hex,
7210 crate::stored_event::SystemEventType::PinsModified,
7211 actor_npub,
7212 None,
7213 at_secs,
7214 None,
7215 None,
7216 )
7217 .await
7218 .unwrap_or(false);
7219 if inserted {
7220 crate::emit_event(
7221 "system_event",
7222 &serde_json::json!({
7223 "conversation_id": channel_hex,
7224 "event_id": event_id,
7225 "event_type": crate::stored_event::SystemEventType::PinsModified.as_u8(),
7226 "member_pubkey": actor_npub,
7227 }),
7228 );
7229 }
7230}
7231
7232fn writable_pin_entries(community: &CommunityV2, channel_id: &ChannelId) -> Result<Vec<super::pins::PinEntry>, String> {
7236 let current = read_channel_pins(community, channel_id)?;
7237 if current.sealed {
7238 return Err("this channel's pins are sealed under a key you don't hold; pinning would erase them".to_string());
7239 }
7240 Ok(current.pins.into_iter().map(|p| p.entry).collect())
7241}
7242
7243pub async fn pin_message<T: Transport + ?Sized>(
7248 transport: &T,
7249 community: &CommunityV2,
7250 channel_id: &ChannelId,
7251 rumor_id_hex: &str,
7252) -> Result<(), String> {
7253 crate::db::scoped(async move {
7254 let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
7255 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
7256
7257 let mut entries = writable_pin_entries(community, channel_id)?;
7258 if entries.len() >= super::pins::PIN_MAX_ENTRIES {
7259 return Err(format!("this channel already holds {} pins; unpin one first", super::pins::PIN_MAX_ENTRIES));
7260 }
7261 if entries
7263 .iter()
7264 .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
7265 .any(|v| v.rumor_id == rumor_id_hex)
7266 {
7267 return Ok(());
7268 }
7269
7270 let (wrap_id, _tags) = crate::db::events::get_event_wrap_context(rumor_id_hex)?
7271 .ok_or("message not found in this device's history")?;
7272 let wrap_id = wrap_id.ok_or("this message's original wrap id was not recorded")?;
7273
7274 let wraps = transport
7277 .fetch(
7278 &Query {
7279 ids: vec![wrap_id.clone()],
7280 kinds: vec![super::stream::KIND_WRAP],
7281 limit: Some(1),
7282 evidence: crate::community::transport::Evidence::Full,
7283 ..Default::default()
7284 },
7285 &community.relays,
7286 )
7287 .await?;
7288 let wrap = wraps
7289 .iter()
7290 .find(|w| w.id.to_hex() == wrap_id)
7291 .ok_or("the message's wrap is no longer served by this community's relays")?;
7292
7293 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7299 let mut coords: Vec<(u64, [u8; 32])> = Vec::new();
7300 if ch.private {
7301 if let Some(k) = ch.key {
7302 coords.push((ch.epoch.0, k));
7303 }
7304 let held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
7305 coords.extend(held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (ep.0, k)));
7306 } else {
7307 coords.push((community.root_epoch.0, community.community_root));
7308 let held = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
7309 coords.extend(held.into_iter().map(|(ep, k)| (ep.0, k)));
7310 }
7311 coords.dedup();
7312
7313 let mut found: Option<(super::stream::OpenedStream, [u8; 32])> = None;
7314 for (epoch, ikm) in coords {
7315 let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
7316 if let Ok(super::chat::ChatEvent::Message { opened, .. }) =
7317 super::chat::open_chat_event(wrap, &group, channel_id, Epoch(epoch))
7318 {
7319 if opened.rumor_id.to_hex() == rumor_id_hex {
7320 let conv: [u8; 32] = group
7321 .conv_key()
7322 .as_bytes()
7323 .try_into()
7324 .map_err(|_| "conversation key size".to_string())?;
7325 found = Some((opened, conv));
7326 break;
7327 }
7328 }
7329 }
7330 let Some((opened, conv_key)) = found else {
7331 return Err("that message is from a key epoch this device no longer holds".to_string());
7332 };
7333
7334 let entry = super::pins::build_pin_entry(&opened, &conv_key, &ch_hex).map_err(|e| match e {
7335 super::pins::PinBuildFailure::NotEncrypted => "this message's seal form cannot be pinned".to_string(),
7336 super::pins::PinBuildFailure::BadPayload => "that message is from a key epoch this device no longer holds".to_string(),
7337 super::pins::PinBuildFailure::Unverifiable => "this message's proof did not verify".to_string(),
7338 })?;
7339 entries.push(entry);
7340
7341 publish_pin_list(transport, community, &ch, &entries).await
7342 })
7343 .await
7344}
7345
7346fn channel_conv_ikm(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Result<[u8; 32], String> {
7349 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7350 if ch.private {
7351 if ch.epoch.0 == epoch {
7352 return ch.key.ok_or("this private channel's key has not arrived yet".to_string());
7353 }
7354 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
7355 crate::db::community::held_epoch_keys(&cid_hex, &ch_hex)
7356 .unwrap_or_default()
7357 .into_iter()
7358 .find(|(ep, _)| ep.0 == epoch)
7359 .map(|(_, k)| k)
7360 .ok_or("that message is from a key epoch this device no longer holds".to_string())
7361 } else if community.root_epoch.0 == epoch {
7362 Ok(community.community_root)
7363 } else {
7364 crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
7365 .unwrap_or_default()
7366 .into_iter()
7367 .find(|(ep, _)| ep.0 == epoch)
7368 .map(|(_, k)| k)
7369 .ok_or("that message is from a root epoch this device no longer holds".to_string())
7370 }
7371}
7372
7373pub async fn unpin_message<T: Transport + ?Sized>(
7375 transport: &T,
7376 community: &CommunityV2,
7377 channel_id: &ChannelId,
7378 rumor_id_hex: &str,
7379) -> Result<(), String> {
7380 let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
7381 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
7382 let entries = writable_pin_entries(community, channel_id)?;
7383 let kept: Vec<super::pins::PinEntry> = entries
7384 .into_iter()
7385 .filter(|e| {
7386 super::pins::verify_pin_entry(e, &ch_hex)
7387 .map(|v| v.rumor_id != rumor_id_hex)
7388 .unwrap_or(true)
7391 })
7392 .collect();
7393 publish_pin_list(transport, community, &ch, &kept).await
7394}
7395
7396pub(crate) fn spawn_pin_duty(channel_hex: &str, target_rumor_hex: &str, edit: Option<super::stream::OpenedStream>) {
7400 let channel_hex = channel_hex.to_string();
7401 let target = target_rumor_hex.to_string();
7402 crate::db::spawn_bound(async move {
7403 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
7404 let _ = run_pin_duty(&transport, &channel_hex, &target, edit).await;
7405 });
7406}
7407
7408async fn run_pin_duty<T: Transport + ?Sized>(
7415 transport: &T,
7416 channel_hex: &str,
7417 target: &str,
7418 edit: Option<super::stream::OpenedStream>,
7419) -> Result<(), String> {
7420 use crate::community::roles::Permissions;
7421 let Some(cid_hex) = crate::db::community::community_id_for_channel(channel_hex)? else {
7422 return Ok(());
7423 };
7424 let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
7425 let Some(community) = crate::db::community::load_community_v2(&cid)? else {
7426 return Ok(());
7427 };
7428 let channel_id = ChannelId(crate::simd::hex::hex_to_bytes_32(channel_hex));
7429
7430 let read = read_channel_pins(&community, &channel_id)?;
7432 if read.sealed {
7433 return Ok(());
7434 }
7435 let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
7436 return Ok(());
7437 };
7438 if let Some(ed) = &edit {
7439 if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
7441 return Ok(());
7442 }
7443 }
7444 let me = me_pk()?;
7445 let me_hex = me.to_hex();
7446 let owner_hex = community.owner()?.to_hex();
7447 let roster = crate::db::community::get_community_roles(&cid_hex)?;
7448 if !roster.is_authorized(&me_hex, Some(&owner_hex), Permissions::PIN_MESSAGES) {
7449 return Ok(());
7450 }
7451
7452 if hit.author != me_hex {
7453 let mut h: u32 = 0;
7454 for b in me_hex.bytes().chain(target.bytes()) {
7455 h = h.wrapping_mul(31).wrapping_add(u32::from(b));
7456 }
7457 tokio::time::sleep(std::time::Duration::from_secs(5 + u64::from(h % 21))).await;
7458 }
7459
7460 let read = read_channel_pins(&community, &channel_id)?;
7462 if read.sealed {
7463 return Ok(());
7464 }
7465 let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
7466 return Ok(());
7467 };
7468 let ch = community.channel(&channel_id).ok_or("no such channel")?.clone();
7469
7470 let entries: Vec<super::pins::PinEntry> = match &edit {
7471 None => read
7473 .pins
7474 .iter()
7475 .filter(|p| p.rumor_id != target)
7476 .map(|p| p.entry.clone())
7477 .collect(),
7478 Some(ed) => {
7480 if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
7481 return Ok(());
7482 }
7483 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
7484 let mut bundle = None;
7487 let mut epochs: Vec<u64> = vec![if ch.private { ch.epoch.0 } else { community.root_epoch.0 }];
7488 let scope = if ch.private { ch_hex.clone() } else { crate::community::SERVER_ROOT_SCOPE_HEX.to_string() };
7489 epochs.extend(
7490 crate::db::community::held_epoch_keys(&cid_hex, &scope)
7491 .unwrap_or_default()
7492 .into_iter()
7493 .map(|(ep, _)| ep.0),
7494 );
7495 epochs.dedup();
7496 for epoch in epochs {
7497 let Some(conv) = channel_conv_key_at(&community, &ch, epoch) else { continue };
7498 if let Ok(b) = super::pins::build_pin_edit_bundle(ed, &conv, &hit.author, target, &ch_hex) {
7499 bundle = Some(b);
7500 break;
7501 }
7502 }
7503 let Some(bundle) = bundle else { return Ok(()) };
7504 read.pins
7505 .iter()
7506 .map(|p| {
7507 let mut entry = p.entry.clone();
7508 if p.rumor_id == target {
7509 entry.edit = Some(bundle.clone());
7510 }
7511 entry
7512 })
7513 .collect()
7514 }
7515 };
7516
7517 crate::log_info!(
7518 "[pins] duty {} for target {} in channel {}",
7519 if edit.is_some() { "edit-refresh" } else { "omission" },
7520 &target[..12],
7521 &channel_hex[..12]
7522 );
7523 publish_pin_list(transport, &community, &ch, &entries).await
7524}
7525
7526pub async fn upgrade_admin_role_pin_bit<T: Transport + ?Sized>(
7531 transport: &T,
7532 community: &CommunityV2,
7533) -> Result<bool, String> {
7534 use crate::community::roles::{Permissions, RoleScope};
7535 let my_pk = me_pk()?;
7536 if community.owner()? != my_pk {
7537 return Ok(false);
7538 }
7539 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7540 let roles = crate::db::community::get_community_roles(&cid_hex)?;
7541 let Some(role) = roles.roles.iter().find(|r| {
7542 matches!(r.scope, RoleScope::Server)
7543 && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK)
7544 && !r.permissions.contains(Permissions::PIN_MESSAGES)
7545 }) else {
7546 return Ok(false);
7547 };
7548 let mut widened = role.clone();
7549 widened.permissions.0 |= Permissions::PIN_MESSAGES;
7550 set_role(transport, community, &widened).await?;
7551 Ok(true)
7552}
7553
7554#[cfg(test)]
7555mod tests {
7556 use super::super::derive::control_group_key;
7559 use crate::ClientRelayExt;
7560 use nostr_sdk::prelude::FinalizeEvent;
7561 use super::super::super::transport::memory::MemoryRelay;
7562 use super::*;
7563 use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
7564
7565 fn account_name(n: u32) -> String {
7567 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
7568 let mut acct = String::from("npub1");
7569 let mut v = n as usize;
7570 for _ in 0..58 {
7571 acct.push(B[v % 32] as char);
7572 v = v / 32 + 7;
7573 }
7574 acct
7575 }
7576
7577 struct Actor {
7579 keys: Keys,
7580 account: String,
7581 }
7582
7583 struct TestBed {
7589 _tmp: tempfile::TempDir,
7590 _guard: std::sync::MutexGuard<'static, ()>,
7591 relay: MemoryRelay,
7592 relays: Vec<String>,
7593 }
7594
7595 impl TestBed {
7596 fn new() -> (TestBed, Actor, Actor) {
7597 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
7598 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
7599 crate::db::close_database();
7600 crate::db::clear_id_caches();
7601 let tmp = tempfile::tempdir().unwrap();
7602 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
7603
7604 let mk = || {
7605 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7606 let account = account_name(n);
7607 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
7608 crate::db::set_current_account(account.clone()).unwrap();
7609 crate::db::init_database(&account).unwrap();
7610 Actor { keys: Keys::generate(), account }
7611 };
7612 let owner = mk();
7613 let member = mk();
7614 let _ = crate::state::take_nostr_client();
7615 let bed = TestBed {
7616 _tmp: tmp,
7617 _guard: guard,
7618 relay: MemoryRelay::new(),
7619 relays: vec!["wss://r".to_string()],
7620 };
7621 (bed, owner, member)
7622 }
7623
7624 fn swap_to(&self, actor: &Actor) {
7629 crate::db::close_database();
7630 crate::db::set_current_account(actor.account.clone()).unwrap();
7631 crate::db::init_database(&actor.account).unwrap();
7632 crate::db::clear_id_caches();
7633 crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
7634 crate::state::set_my_public_key(actor.keys.public_key());
7635 }
7636 }
7637
7638 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
7640 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
7641 crate::db::close_database();
7642 crate::db::clear_id_caches();
7643 let tmp = tempfile::tempdir().unwrap();
7644 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
7645 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7646 let acct = account_name(n);
7647 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
7648 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
7649 crate::db::set_current_account(acct.clone()).unwrap();
7650 crate::db::init_database(&acct).unwrap();
7651 let _ = crate::state::take_nostr_client();
7652 let owner = Keys::generate();
7653 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
7654 crate::state::set_my_public_key(owner.public_key());
7655 (tmp, guard, owner)
7656 }
7657
7658 struct SwapMidFetch<'a> {
7665 inner: &'a MemoryRelay,
7666 to: String,
7667 armed: std::sync::atomic::AtomicBool,
7668 }
7669
7670 impl<'a> SwapMidFetch<'a> {
7671 fn arm(inner: &'a MemoryRelay) -> Self {
7672 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(88_000);
7673 let to = account_name(N.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
7674 std::fs::create_dir_all(crate::db::shared_test_data_dir().join(&to)).unwrap();
7675 Self { inner, to, armed: std::sync::atomic::AtomicBool::new(true) }
7676 }
7677 }
7678
7679 #[async_trait::async_trait]
7680 impl Transport for SwapMidFetch<'_> {
7681 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
7682 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
7683 self.inner.publish(e, r).await
7684 }
7685 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
7686 self.inner.publish_durable(e, r).await
7687 }
7688 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
7689 let out = self.inner.fetch(q, r).await;
7690 if self.armed.swap(false, std::sync::atomic::Ordering::SeqCst) {
7691 crate::db::set_current_account(self.to.clone()).unwrap();
7692 crate::db::init_database(&self.to).unwrap();
7693 }
7694 out
7695 }
7696 }
7697
7698 struct SwapMidPublish {
7701 inner: MemoryRelay,
7702 }
7703 #[async_trait::async_trait]
7704 impl Transport for SwapMidPublish {
7705 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
7706 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
7707 self.inner.publish(e, r).await
7708 }
7709 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
7710 let out = self.inner.publish_durable(e, r).await;
7711 crate::db::close_database();
7712 out
7713 }
7714 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
7715 self.inner.fetch(q, r).await
7716 }
7717 }
7718
7719 struct FixedFetch {
7724 events: Vec<Event>,
7725 }
7726 #[async_trait::async_trait]
7727 impl Transport for FixedFetch {
7728 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
7729 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
7730 Ok(())
7731 }
7732 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
7733 Ok(())
7734 }
7735 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
7736 Ok(self.events.clone())
7737 }
7738 }
7739
7740 async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
7743 let q = Query {
7744 kinds: vec![stream::KIND_WRAP],
7745 p_tags: vec![me.to_hex()],
7746 k_tags: vec!["3313".to_string()],
7747 ..Default::default()
7748 };
7749 relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
7750 }
7751
7752 #[tokio::test]
7753 async fn create_persists_and_reloads_a_v2_community() {
7754 let (_tmp, _guard, owner) = init_test_db();
7755 let relay = MemoryRelay::new();
7756 let relays = vec!["wss://r".to_string()];
7757
7758 let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
7759 assert!(created.identity.verify());
7760 assert_eq!(created.owner().unwrap(), owner.public_key());
7761 assert_eq!(created.channels.len(), 1);
7762
7763 assert_eq!(
7765 crate::db::community::community_protocol(created.id()).unwrap(),
7766 Some(crate::community::ConcordProtocol::V2)
7767 );
7768 let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
7769 assert_eq!(loaded.name, "Vectorville");
7770 assert_eq!(loaded.community_root, created.community_root);
7771 assert_eq!(loaded.identity, created.identity);
7772 assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
7773 assert!(!loaded.channels[0].private);
7774
7775 assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
7777 }
7778
7779 #[tokio::test]
7780 async fn owner_sends_and_reads_back_a_message() {
7781 let (_tmp, _guard, _owner) = init_test_db();
7782 let relay = MemoryRelay::new();
7783 let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
7784 let general = community.channels[0].id;
7785
7786 let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
7787 let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
7788 assert_ne!(id1, id2);
7789
7790 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
7791 let texts: Vec<String> = page
7792 .iter()
7793 .filter_map(|f| match &f.event {
7794 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
7795 _ => None,
7796 })
7797 .collect();
7798 assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
7799 }
7800
7801 #[test]
7802 fn send_stamps_never_repeat_within_a_process() {
7803 let stamps: Vec<u64> = (0..5_000).map(|_| next_send_ms()).collect();
7811 for pair in stamps.windows(2) {
7812 assert!(pair[1] > pair[0], "stamps must strictly increase: {} then {}", pair[0], pair[1]);
7813 }
7814 }
7815
7816 #[tokio::test]
7817 async fn same_millisecond_messages_order_by_rumor_id_not_relay_order() {
7818 let (_tmp, _guard, _owner) = init_test_db();
7824 let relay = MemoryRelay::new();
7825 let community = create_community(&relay, "Ties", vec!["wss://r".into()], None).await.unwrap();
7826 let general = community.channels[0].id;
7827
7828 let at = now_ms();
7829 for i in 0..6 {
7830 send_chat_message_at(&relay, &community, &general, &format!("tied {i}"), None, &[], vec![], at)
7831 .await
7832 .unwrap();
7833 }
7834 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
7835 let msgs: Vec<(Vec<u8>, String)> = page
7836 .iter()
7837 .filter_map(|f| match &f.event {
7838 ChatEvent::Message { .. } => {
7839 let o = f.event.opened();
7840 Some((o.rumor_id.as_bytes().to_vec(), o.rumor.content.clone()))
7841 }
7842 _ => None,
7843 })
7844 .collect();
7845 assert_eq!(msgs.len(), 6, "all six tied messages read back");
7846 let got: Vec<String> = msgs.iter().map(|(_, c)| c.clone()).collect();
7847 let mut by_id = msgs.clone();
7848 by_id.sort_by(|a, b| a.0.cmp(&b.0));
7849 let expected: Vec<String> = by_id.into_iter().map(|(_, c)| c).collect();
7850 assert_eq!(got, expected, "ties resolve by rumor id, never by the relay's serving order");
7851 }
7852
7853 #[tokio::test]
7854 async fn a_rapid_burst_carries_strictly_increasing_stamps() {
7855 let (_tmp, _guard, _owner) = init_test_db();
7860 let relay = MemoryRelay::new();
7861 let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
7862 let general = community.channels[0].id;
7863 for i in 0..8 {
7864 send_message(&relay, &community, &general, &format!("msg {i}")).await.unwrap();
7865 }
7866 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
7867 let stamps: Vec<u64> = page
7868 .iter()
7869 .filter_map(|f| match &f.event {
7870 ChatEvent::Message { .. } => Some(f.event.opened().at_ms),
7871 _ => None,
7872 })
7873 .collect();
7874 assert_eq!(stamps.len(), 8);
7875 for pair in stamps.windows(2) {
7876 assert!(pair[1] > pair[0], "a sender's own stamps must strictly increase, got {pair:?}");
7877 }
7878 }
7879
7880 #[tokio::test]
7881 async fn a_second_member_reads_the_public_channel_from_the_root() {
7882 let (_tmp, _guard, _owner) = init_test_db();
7886 let relay = MemoryRelay::new();
7887 let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
7888 let general = community.channels[0].id;
7889 send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
7890
7891 let member_view = community.clone();
7893 let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
7894 assert_eq!(page.len(), 1);
7895 assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
7896 assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
7897 }
7898
7899 async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
7902 fetch_channel(relay, community, channel, 100)
7903 .await
7904 .unwrap()
7905 .iter()
7906 .filter_map(|f| match &f.event {
7907 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
7908 _ => None,
7909 })
7910 .collect()
7911 }
7912
7913 async fn all_texts_in<T: crate::community::transport::Transport + ?Sized>(
7917 relay: &T,
7918 community: &CommunityV2,
7919 channel: &ChannelId,
7920 ) -> Vec<String> {
7921 let from_now = now_ms() / 1000 + 3600;
7922 fetch_channel_history(
7923 relay,
7924 community,
7925 channel,
7926 100,
7927 8,
7928 None,
7929 Some(from_now),
7930 crate::community::transport::Evidence::Quorum,
7931 |_| true,
7932 )
7933 .await
7934 .unwrap()
7935 .iter()
7936 .filter_map(|f| match &f.event {
7937 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
7938 _ => None,
7939 })
7940 .collect()
7941 }
7942
7943 #[tokio::test]
7944 async fn direct_invite_full_loop_owner_and_member_converse() {
7945 let (bed, owner, member) = TestBed::new();
7946
7947 bed.swap_to(&owner);
7949 let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
7950 let general = community.channels[0].id;
7951 send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
7952 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
7953
7954 bed.swap_to(&member);
7956 assert!(
7957 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
7958 "the member does not hold the community before joining"
7959 );
7960 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7961 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
7962 assert_eq!(joined.id().0, community.id().0, "joined the same community");
7963 assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
7964 assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
7965
7966 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
7968 send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
7969
7970 bed.swap_to(&owner);
7972 assert_eq!(
7973 texts_in(&bed.relay, &community, &general).await,
7974 vec!["owner: welcome!", "member: thanks for the invite"],
7975 "both actors' messages interleave in ms order on the shared channel"
7976 );
7977
7978 let members = memberlist(&bed.relay, &community).await.unwrap();
7980 assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
7981 assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
7982 assert_eq!(members.len(), 2);
7983 }
7984
7985 #[tokio::test]
7989 async fn a_banned_member_is_refused_at_join_time() {
7990 let (bed, owner, member) = TestBed::new();
7991
7992 bed.swap_to(&owner);
7993 let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
7994 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
7995 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
7996
7997 bed.swap_to(&member);
7998 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7999 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
8000 assert!(err.contains("banned"), "refusal names the reason: {err}");
8001 assert!(
8002 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
8003 "a refused join persists nothing"
8004 );
8005
8006 bed.swap_to(&owner);
8009 set_banlist(&bed.relay, &community, &[]).await.unwrap();
8010 bed.swap_to(&member);
8011 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
8012 assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
8013 }
8014
8015 #[tokio::test]
8019 async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
8020 use crate::community::migration;
8021 let (bed, owner, member) = TestBed::new();
8022
8023 bed.swap_to(&owner);
8025 let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
8026 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
8027 let jm = join_material(&v2);
8028
8029 bed.swap_to(&member);
8032 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8033 let v1_cid = v1.id.to_hex();
8034 v1.owner_attestation = Some({
8035 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8036 .finalize(&owner.keys).unwrap().as_json()
8037 });
8038 crate::db::community::save_community(&v1).unwrap();
8039 let v1_channel = v1.channels[0].id.to_hex();
8040
8041 let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
8043 let signpost = migration::MigrationSignpost {
8044 v2_community_id: v2_hex.clone(),
8045 owner_xonly: owner.keys.public_key().to_hex(),
8046 owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
8047 relays: bed.relays.clone(),
8048 name: "Guild".into(),
8049 primary_channel: v1_channel.clone(),
8050 root_epoch: 0,
8051 };
8052 let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
8053 crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
8054
8055 let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
8057 assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
8058
8059 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
8061 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
8062 assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
8063 let _ = v1_channel;
8064
8065 assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
8067 }
8068
8069 #[tokio::test]
8074 async fn owner_wizard_then_member_migrate_and_stitch() {
8075 use crate::community::migration;
8076 let (bed, owner, member) = TestBed::new();
8077 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8078
8079 bed.swap_to(&owner);
8081 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8082 let v1_cid = v1.id.to_hex();
8083 let v1_channel = v1.channels[0].id.to_hex();
8084 v1.owner_attestation = Some({
8085 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8086 .finalize(&owner.keys).unwrap().as_json()
8087 });
8088 crate::db::community::save_community(&v1).unwrap();
8089
8090 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8092 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
8093 "owner's own client flipped to v2");
8094 assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
8097 "owner channel stitched to v2");
8098
8099 bed.swap_to(&member);
8101 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8102 m_v1.id = v1.id;
8104 m_v1.server_root_key = v1.server_root_key.clone();
8105 m_v1.channels[0].id = v1.channels[0].id;
8106 m_v1.owner_attestation = v1.owner_attestation.clone();
8107 crate::db::community::save_community(&m_v1).unwrap();
8108
8109 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
8112 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
8113 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
8114 "the FOLD ITSELF flipped the member (auto-drive)");
8115 assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
8116 "member holds the v2 twin");
8117 assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
8119 }
8120
8121 #[tokio::test]
8126 async fn wizard_publishes_the_twin_to_the_cross_device_list() {
8127 use crate::community::migration;
8128 let (bed, owner, _member) = TestBed::new();
8129 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8130
8131 bed.swap_to(&owner);
8132 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8133 let v1_cid = v1.id.to_hex();
8134 v1.owner_attestation = Some({
8135 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8136 .finalize(&owner.keys).unwrap().as_json()
8137 });
8138 crate::db::community::save_community(&v1).unwrap();
8139
8140 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8141
8142 let list = fetch_fragments(&bed.relay, &bed.relays).await.unwrap()
8144 .expect("the wizard published a community list").list;
8145 assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
8146 assert!(
8150 !list.tombstones.iter().any(|t| t.community_id == v1_cid),
8151 "migration must not tombstone the v1 community"
8152 );
8153 }
8154
8155 #[tokio::test]
8160 async fn wizard_refuses_while_a_drive_holds_the_claim() {
8161 use crate::community::migration;
8162 let (bed, owner, _member) = TestBed::new();
8163 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8164
8165 bed.swap_to(&owner);
8166 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8167 let v1_cid = v1.id.to_hex();
8168 v1.owner_attestation = Some({
8169 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8170 .finalize(&owner.keys).unwrap().as_json()
8171 });
8172 crate::db::community::save_community(&v1).unwrap();
8173
8174 migration::test_hold_drive_claim(&v1_cid);
8176 let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
8177 assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
8178 assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
8180 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
8181
8182 migration::test_release_drive_claim(&v1_cid);
8184 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8185 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
8186 }
8187
8188 #[tokio::test]
8194 async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
8195 use crate::community::migration;
8196 let (bed, owner, _member) = TestBed::new();
8197 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8198 let relay = std::sync::Arc::new(MemoryRelay::new());
8200
8201 bed.swap_to(&owner);
8202 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8203 let v1_cid = v1.id.to_hex();
8204 let v1_channel = v1.channels[0].id.to_hex();
8205 v1.owner_attestation = Some({
8206 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8207 .finalize(&owner.keys).unwrap().as_json()
8208 });
8209 crate::db::community::save_community(&v1).unwrap();
8210
8211 let twin = create_migration_twin(
8214 &*relay, "Guild", bed.relays.clone(), None,
8215 (v1.channels[0].id, "general".to_string()),
8216 ).await.unwrap();
8217 let v2_id = twin.identity.community_id;
8218 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
8219 crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
8220
8221 let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
8223
8224 let wizard = tokio::spawn({
8225 let relay = relay.clone();
8226 let v1 = v1.clone();
8227 async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
8228 });
8229
8230 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
8232 assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
8233 assert!(
8234 crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
8235 "the fence must not be stamped while the follow lock is held"
8236 );
8237
8238 drop(held);
8240 let flipped = wizard.await.unwrap().unwrap();
8241 assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
8242 assert_eq!(
8243 crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
8244 Some(v2_hex.as_str()),
8245 "the channel row is stitched to the twin, not pruned"
8246 );
8247 }
8248
8249 #[tokio::test]
8254 async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
8255 use crate::community::migration;
8256 let (bed, owner, banned) = TestBed::new();
8257 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8258
8259 bed.swap_to(&owner);
8261 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8262 let v1_cid = v1.id.to_hex();
8263 v1.owner_attestation = Some({
8264 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8265 .finalize(&owner.keys).unwrap().as_json()
8266 });
8267 crate::db::community::save_community(&v1).unwrap();
8268 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
8269
8270 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8271
8272 bed.swap_to(&banned);
8274 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8275 m_v1.id = v1.id;
8276 m_v1.server_root_key = v1.server_root_key.clone();
8277 m_v1.channels[0].id = v1.channels[0].id;
8278 m_v1.owner_attestation = v1.owner_attestation.clone();
8279 crate::db::community::save_community(&m_v1).unwrap();
8280 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
8281
8282 let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
8284 let payload = migration::parse_migration_payload(&raw).unwrap();
8285 assert!(payload.m.is_some());
8286 let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
8287 assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
8288 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
8289 assert!(
8290 crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
8291 "banned member never acquires the v2 twin"
8292 );
8293 }
8294
8295 #[tokio::test]
8300 async fn wizard_resume_continues_on_the_same_twin() {
8301 use crate::community::migration;
8302 let (bed, owner, banned) = TestBed::new();
8303 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8304
8305 bed.swap_to(&owner);
8306 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8307 let mut sibling = v1.channels[0].clone();
8309 sibling.id = crate::community::ChannelId(crate::community::random_32());
8310 sibling.name = "offtopic".into();
8311 v1.channels.push(sibling.clone());
8312 let v1_cid = v1.id.to_hex();
8313 v1.owner_attestation = Some({
8314 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8315 .finalize(&owner.keys).unwrap().as_json()
8316 });
8317 crate::db::community::save_community(&v1).unwrap();
8318 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
8319
8320 let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
8323 let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
8324 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
8325
8326 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8328 assert_eq!(v2_hex, minted_hex, "no second twin was minted");
8329 let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
8330 assert_eq!(ledger_v2, minted_hex);
8331 assert_eq!(phase, migration::PHASE_FLIPPED);
8332 assert_eq!(
8335 crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
8336 Some(minted_hex.as_str()),
8337 "sibling channel re-parented by the resumed run"
8338 );
8339 let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
8340 let wire_banlist = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
8341 .await
8342 .unwrap()
8343 .banned;
8344 assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
8345 "the resumed banlist clone is folded from the twin's wire control plane");
8346
8347 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
8350 let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8351 assert_eq!(healed, minted_hex);
8352 let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
8353 assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
8354
8355 let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
8361 let v1b_cid = v1b.id.to_hex();
8362 v1b.owner_attestation = Some({
8363 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
8364 .finalize(&owner.keys).unwrap().as_json()
8365 });
8366 crate::db::community::save_community(&v1b).unwrap();
8367 let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
8368 let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
8369 crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
8370 crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
8372 assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
8373 assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
8374 }
8375
8376 #[tokio::test]
8381 async fn birth_refound_seeds_an_explicit_roster() {
8382 let (bed, owner, _m) = TestBed::new();
8383 bed.swap_to(&owner);
8384 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
8385 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
8386 assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
8387 let ghost_a = Keys::generate().public_key();
8389 let ghost_b = Keys::generate().public_key();
8390
8391 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
8392 assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
8393
8394 let members = memberlist(&bed.relay, &rolled).await.unwrap();
8397 assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
8398 assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
8399
8400 let _ = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
8404 .expect("the epoch-1 twin verifies from its compacted control plane");
8405
8406 let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
8409 assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
8410 assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
8411 }
8412
8413 #[tokio::test]
8417 async fn birth_refound_ignores_a_banned_seed_entry() {
8418 let (bed, owner, _m) = TestBed::new();
8419 bed.swap_to(&owner);
8420 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
8421 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
8422 let good = Keys::generate().public_key();
8423 let banned = Keys::generate();
8424 set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
8426 let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
8427
8428 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
8429 .expect("a banned seed entry is filtered, not a permanent verify-back wedge");
8430 assert_eq!(rolled.root_epoch, Epoch(1));
8431 let members = memberlist(&bed.relay, &rolled).await.unwrap();
8432 assert!(members.contains(&good), "the non-banned seed lands");
8433 assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
8434 }
8435
8436 #[tokio::test]
8442 async fn a_seeded_member_receives_a_later_refound_rekey() {
8443 let (bed, owner, _m) = TestBed::new();
8444 bed.swap_to(&owner);
8445 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
8446 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
8447 let ghost = Keys::generate();
8448 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
8450 assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
8451
8452 let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
8458 assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
8459 assert!(
8460 memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
8461 "a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
8462 );
8463 }
8464
8465 #[tokio::test]
8468 async fn v1_admin_stays_admin_across_migration() {
8469 use crate::community::migration;
8470 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
8471 let (bed, owner, admin) = TestBed::new();
8472 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8473
8474 bed.swap_to(&owner);
8475 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8476 let v1_cid = v1.id.to_hex();
8477 v1.owner_attestation = Some({
8478 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8479 .finalize(&owner.keys).unwrap().as_json()
8480 });
8481 crate::db::community::save_community(&v1).unwrap();
8482 let admin_role = Role::admin("a1".repeat(32));
8484 let roles = CommunityRoles {
8485 roles: vec![admin_role.clone()],
8486 grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
8487 };
8488 crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
8489
8490 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8491 let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
8492
8493 let authority = fetch_authority(&bed.relay, &twin).await;
8495 assert!(
8496 authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
8497 "the v1 admin is an admin on the v2 twin"
8498 );
8499 assert!(
8500 !authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
8501 "a non-admin gains no authority"
8502 );
8503 }
8504
8505 #[tokio::test]
8514 async fn an_unsealed_v1_that_was_migrated_away_still_heals() {
8515 use crate::community::migration;
8516 let (bed, owner, member) = TestBed::new();
8517 bed.swap_to(&owner);
8518
8519 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8520 let v1_cid = v1.id.to_hex();
8521 v1.owner_attestation = Some({
8522 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8523 .finalize(&owner.keys).unwrap().as_json()
8524 });
8525 crate::db::community::save_community(&v1).unwrap();
8526
8527 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8529 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8530
8531 bed.swap_to(&member);
8534 crate::db::community::save_community(&v1).unwrap();
8535 assert!(
8536 !crate::db::community::get_community_dissolved(&v1_cid).unwrap(),
8537 "precondition: the wedged device has NOT sealed its v1 row"
8538 );
8539
8540 assert!(
8542 crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
8543 "an unsealed, migrated-away v1 must be probed"
8544 );
8545
8546 migration::sweep_dissolved_for_migration(&bed.relay).await;
8547
8548 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "sealed by the sweep");
8550 assert_eq!(
8551 crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(),
8552 Some(v2_hex.as_str()),
8553 "flipped to the same twin the first device produced"
8554 );
8555 assert!(
8556 !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
8557 "and the sweep converges — no re-probing forever"
8558 );
8559 }
8560
8561 #[tokio::test]
8566 async fn sweep_marks_checked_only_on_an_owner_tombstone() {
8567 use crate::community::migration;
8568 let (bed, owner, stranger) = TestBed::new();
8569
8570 bed.swap_to(&owner);
8571 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8572 let v1_cid = v1.id.to_hex();
8573 v1.owner_attestation = Some({
8574 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8575 .finalize(&owner.keys).unwrap().as_json()
8576 });
8577 crate::db::community::save_community(&v1).unwrap();
8578
8579 let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
8582 let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
8583 bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
8584 crate::db::community::set_community_dissolved(&v1_cid).unwrap();
8585
8586 migration::sweep_dissolved_for_migration(&bed.relay).await;
8588 assert!(
8589 crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
8590 "a stranger-only probe must not converge the sweep"
8591 );
8592
8593 let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
8595 let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
8596 bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
8597 migration::sweep_dissolved_for_migration(&bed.relay).await;
8598 assert!(
8599 !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
8600 "an owner plain-dissolution converges the sweep"
8601 );
8602 }
8603
8604 #[tokio::test]
8606 async fn wizard_preflight_gates_timelock_and_ownership() {
8607 use crate::community::migration;
8608 let (bed, owner, _member) = TestBed::new();
8609 bed.swap_to(&owner);
8610 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8611 v1.owner_attestation = Some({
8612 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
8613 .finalize(&owner.keys).unwrap().as_json()
8614 });
8615 crate::db::community::save_community(&v1).unwrap();
8616
8617 let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
8619 assert!(err.contains("not unlocked"), "{err}");
8620 assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
8621 }
8622
8623 #[tokio::test]
8624 async fn public_link_full_loop() {
8625 let (bed, owner, member) = TestBed::new();
8626
8627 bed.swap_to(&owner);
8628 let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
8629 let general = community.channels[0].id;
8630 send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
8631 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8633 assert!(link.url.starts_with("https://vectorapp.io/invite/"));
8634 assert!(link.url.contains('#'), "the fragment carries the token");
8635
8636 bed.swap_to(&member);
8638 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
8639 assert_eq!(joined.id().0, community.id().0);
8640 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
8641 }
8642
8643 #[test]
8644 fn bundle_of_snapshots_the_held_icon() {
8645 let owner = Keys::generate();
8646 let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
8647 let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
8648 let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
8649 c.icon = Some(icon.clone());
8650 let bundle = bundle_of(&c, BundleAudience::Link, None, None, None);
8651 assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
8652 }
8653
8654 #[test]
8655 fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
8656 let (_tmp, _guard, _owner) = init_test_db();
8661 let cur_root = [9u8; 32];
8662 let cid = crate::community::CommunityId([1u8; 32]);
8663 let cid_hex = cid.to_hex();
8664
8665 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
8667 assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
8668
8669 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
8671 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
8672 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
8673 assert_eq!(roots[0], cur_root, "current root leads");
8674 assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
8675 assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
8676 assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
8678
8679 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
8681 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
8682 assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
8683
8684 for e in 3..20u64 {
8686 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
8687 }
8688 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
8689 assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
8690 }
8691
8692 #[tokio::test]
8693 async fn public_link_preview_shows_live_name_and_icon_without_joining() {
8694 let (bed, owner, member) = TestBed::new();
8695
8696 bed.swap_to(&owner);
8697 let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
8698 let icon = control::ImageRef {
8701 url: "https://blossom.example/soap".into(),
8702 key: "k".into(),
8703 nonce: "n".into(),
8704 hash: "h".into(),
8705 extra: Default::default(),
8706 };
8707 let mut meta = community.metadata();
8708 meta.icon = Some(icon.clone());
8709 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
8710 let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
8712
8713 bed.swap_to(&member);
8715 let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
8716 assert_eq!(preview.name, "Soapbox");
8717 assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
8718 assert!(
8719 crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
8720 "previewing must not persist a membership"
8721 );
8722 }
8723
8724 #[tokio::test]
8725 async fn a_previewed_join_reuses_the_verified_fold() {
8726 let (bed, owner, member) = TestBed::new();
8727 bed.swap_to(&owner);
8728 let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
8729 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8730
8731 bed.swap_to(&member);
8732 let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
8733 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
8734 assert_eq!(joined.id().0, community.id().0);
8735 assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
8736 assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
8739 }
8740
8741 #[tokio::test]
8742 async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
8743 let (bed, owner, member) = TestBed::new();
8744 bed.swap_to(&owner);
8745 let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
8746 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8747
8748 bed.swap_to(&member);
8749 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
8750
8751 assert!(!sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty(), "the seed folds fresh events");
8753 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
8754 let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
8755 assert!(cursor > 0, "the cursor advanced past zero");
8756 let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
8757 let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
8758 assert_eq!(stored, live, "stored fold == live fold after the seed");
8759 assert!(stored.contains(&member.keys.public_key()));
8760
8761 assert!(sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty());
8763
8764 bed.swap_to(&owner);
8766 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
8767 bed.swap_to(&member);
8768 assert!(!sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty(), "the kick lands incrementally");
8769 assert!(
8770 !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
8771 "an owner kick removes the member from the stored fold"
8772 );
8773 }
8774
8775 #[tokio::test]
8776 async fn a_preview_then_revoke_still_refuses_the_join() {
8777 let (bed, owner, member) = TestBed::new();
8778 bed.swap_to(&owner);
8779 let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
8780 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8781
8782 bed.swap_to(&member);
8784 let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
8785 assert_eq!(p.name, "RevokeRace");
8786 bed.swap_to(&owner);
8787 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
8788 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
8789
8790 bed.swap_to(&member);
8793 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
8794 assert!(err.contains("revoked"), "got: {err}");
8795 }
8796
8797 #[tokio::test]
8798 async fn a_revoked_link_refuses_to_join() {
8799 let (bed, owner, member) = TestBed::new();
8800 bed.swap_to(&owner);
8801 let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
8802 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8803 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
8805 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
8806
8807 bed.swap_to(&member);
8808 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
8809 assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
8810 }
8811
8812 #[tokio::test]
8813 async fn an_expired_direct_invite_refuses_to_join() {
8814 let (bed, owner, member) = TestBed::new();
8815 bed.swap_to(&owner);
8816 let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
8817 let inviter = owner.keys.clone();
8819 let mut bundle = bundle_of(&community, BundleAudience::Link, Some(inviter.public_key()), Some(1_000), None);
8820 bundle.expires_at = Some(1_000); let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
8822 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
8823
8824 bed.swap_to(&member);
8825 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
8826 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
8827 assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
8828 }
8829
8830 #[tokio::test]
8831 async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
8832 let (bed, owner, member) = TestBed::new();
8836 bed.swap_to(&owner);
8837 let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
8838 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8839 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
8840
8841 let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
8844
8845 bed.swap_to(&member);
8846 let err = accept_public_link(&union, &link.url).await.unwrap_err();
8847 assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
8848 }
8849
8850 #[test]
8851 fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
8852 let owner = Keys::generate();
8858 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
8859 let hex = crate::simd::hex::bytes_to_hex_32;
8860 let root = [0x11u8; 32];
8861 let mut bundle = CommunityInvite {
8862 community_id: hex(&identity.community_id.0),
8863 owner: hex(&identity.owner_xonly),
8864 owner_salt: hex(&identity.owner_salt),
8865 community_root: hex(&root),
8866 root_epoch: 0,
8867 control_pk: None,
8868 channels: vec![],
8869 relays: vec!["wss://r".into()],
8870 name: "X".into(),
8871 icon: None,
8872 expires_at: None,
8873 creator_npub: None,
8874 label: None,
8875 extra: Default::default(),
8876 };
8877 bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
8878 .map(|i| {
8879 let mut id = [0u8; 32];
8880 id[..8].copy_from_slice(&(i as u64).to_be_bytes());
8881 invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
8882 })
8883 .collect();
8884 assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
8885 }
8886
8887 #[tokio::test]
8888 async fn a_join_swap_between_fetch_and_save_lands_in_the_joining_account() {
8889 let (bed, owner, member) = TestBed::new();
8893 bed.swap_to(&owner);
8894 let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
8895 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8896 bed.swap_to(&member);
8897 let joiner = crate::db::current_session();
8898 let swap_relay = SwapMidFetch::arm(&bed.relay);
8899 accept_public_link(&swap_relay, &link.url).await.unwrap();
8900
8901 assert!(
8902 crate::db::with_session(joiner, async {
8903 crate::db::community::load_community_v2(community.id()).unwrap().is_some()
8904 })
8905 .await,
8906 "the join completed for the account that accepted the link"
8907 );
8908 }
8909
8910 #[tokio::test]
8911 async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
8912 let (_tmp, _guard, owner) = init_test_db();
8915 let relay = MemoryRelay::new();
8916 let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
8917 let empty = MemoryRelay::new();
8920 let members = memberlist(&empty, &community).await.unwrap();
8921 assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
8922 }
8923
8924 #[tokio::test]
8925 async fn an_expiring_minted_invite_refuses_after_the_deadline() {
8926 let (bed, owner, member) = TestBed::new();
8929 bed.swap_to(&owner);
8930 let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
8931 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
8932 .await
8933 .unwrap();
8934
8935 bed.swap_to(&member);
8936 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
8937 assert!(
8938 accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
8939 "a minted expiring invite refuses past its deadline"
8940 );
8941 }
8942
8943 #[tokio::test]
8944 async fn a_member_who_leaves_drops_from_the_memberlist() {
8945 let (bed, owner, member) = TestBed::new();
8946 bed.swap_to(&owner);
8947 let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
8948 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
8949
8950 bed.swap_to(&member);
8951 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
8952 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
8953 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
8955 leave_community(&bed.relay, &joined).await.unwrap();
8956
8957 bed.swap_to(&owner);
8958 let members = memberlist(&bed.relay, &community).await.unwrap();
8959 assert!(members.contains(&owner.keys.public_key()));
8960 assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
8961 }
8962
8963 #[tokio::test]
8964 async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
8965 let (bed, owner, member) = TestBed::new();
8968 bed.swap_to(&owner);
8969 let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
8970 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
8971
8972 bed.swap_to(&member);
8973 assert!(
8974 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
8975 "the owner's community must be invisible in the member's account DB"
8976 );
8977 assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
8978 }
8979
8980 async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
8988 let group = control::ControlPlane::of(&community).write_group().unwrap();
8989 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
8990 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
8991 let mut head: Option<(u64, [u8; 32])> = None;
8992 for w in &wraps {
8993 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
8994 if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
8995 head = Some((ed.version, ed.self_hash));
8996 }
8997 }
8998 }
8999 head.map(|(_, h)| h)
9000 }
9001
9002 async fn cite_on_relay(
9007 relay: &MemoryRelay,
9008 community: &CommunityV2,
9009 signer: &Keys,
9010 ) -> Option<crate::community::edition::AuthorityCitation> {
9011 if community.owner().ok() == Some(signer.public_key()) {
9012 return None;
9013 }
9014 let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
9015 let group = control::ControlPlane::of(&community).write_group().unwrap();
9016 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9017 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
9018 let mut head: Option<(u64, [u8; 32])> = None;
9019 for w in &wraps {
9020 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
9021 if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
9022 head = Some((ed.version, ed.self_hash));
9023 }
9024 }
9025 }
9026 head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
9027 }
9028
9029 async fn publish_channel_edition(
9030 relay: &MemoryRelay,
9031 community: &CommunityV2,
9032 signer: &Keys,
9033 channel_id: &ChannelId,
9034 name: &str,
9035 private: bool,
9036 version: u64,
9037 deleted: bool,
9038 ) {
9039 let group = control::ControlPlane::of(&community).write_group().unwrap();
9040 let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
9041 let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
9042 let content = serde_json::to_string(&meta).unwrap();
9043 let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
9044 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
9045 relay.publish(&wrap, &community.relays).await.unwrap();
9046 }
9047
9048 async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
9051 publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
9052 }
9053
9054 async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
9057 let group = control::ControlPlane::of(&community).write_group().unwrap();
9058 let prev = head_hash_on_relay(relay, community, &community.id().0).await;
9059 let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
9060 let content = serde_json::to_string(&meta).unwrap();
9061 let cite = cite_on_relay(relay, community, signer).await;
9062 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());
9063 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
9064 relay.publish(&wrap, &community.relays).await.unwrap();
9065 }
9066
9067 #[test]
9068 fn metadata_apply_captures_undriven_fields_for_republish() {
9069 let owner = Keys::generate();
9070 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
9071 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
9072 let general = held.channels[0].id;
9073
9074 let mut custom = serde_json::Map::new();
9076 custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
9077 let mut extra = serde_json::Map::new();
9078 extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
9079 let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
9080 assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
9081 assert_eq!(held.meta_custom, Some(custom.clone()));
9082 assert_eq!(held.meta_extra, extra);
9083 assert_eq!(held.metadata().custom, Some(custom));
9085 assert_eq!(held.metadata().extra, held.meta_extra);
9086
9087 let mut ch_custom = serde_json::Map::new();
9089 ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
9090 let ch_meta = control::ChannelMetadata {
9091 name: "general".into(),
9092 private: false,
9093 voice: Some(true),
9094 deleted: None,
9095 custom: Some(ch_custom.clone()),
9096 extra: Default::default(),
9097 };
9098 assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
9099 let ch = held.channel(&general).unwrap();
9100 assert_eq!(ch.voice, Some(true));
9101 assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
9102 let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
9103 assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
9104 assert_eq!(rename.custom, Some(ch_custom));
9105 }
9106
9107 #[test]
9108 fn community_metadata_apply_sets_and_clears_images() {
9109 let owner = Keys::generate();
9110 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
9111 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
9112
9113 let icon = control::ImageRef {
9114 url: "https://blossom.example/i".into(),
9115 key: "k".into(),
9116 nonce: "n".into(),
9117 hash: "h".into(),
9118 extra: Default::default(),
9119 };
9120 let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
9121 assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
9122 assert_eq!(held.icon.as_ref(), Some(&icon));
9123
9124 let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
9126 assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
9127 assert_eq!(held.icon, None);
9128 }
9129
9130 async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
9132 let group = control::ControlPlane::of(&community).write_group().unwrap();
9133 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
9134 let prev = head_hash_on_relay(relay, community, &role_id).await;
9135 let content = crate::community::v2::roles::role_content_json(role).unwrap();
9136 let cite = cite_on_relay(relay, community, signer).await;
9137 let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, cite.as_ref());
9138 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
9139 relay.publish(&wrap, &community.relays).await.unwrap();
9140 }
9141
9142 fn rotate_view(c: &CommunityV2, new_root: [u8; 32], epoch: u64) -> CommunityV2 {
9146 let mut r = c.clone();
9147 r.community_root = new_root;
9148 r.root_epoch = Epoch(epoch);
9149 r.control_root = Some(new_root);
9150 r.control_pk = Some(crate::community::v2::derive::control_signer_group_key(&new_root, r.id(), Epoch(epoch)).pk());
9151 r
9152 }
9153
9154 async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
9160 let group = control::ControlPlane::of(&community).write_group().unwrap();
9161 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
9162 let prev = head_hash_on_relay(relay, community, &eid).await;
9163 let grant = MemberGrant { member: member.to_hex(), role_ids };
9164 let control_wrap = community.control_root.filter(|_| community.control_pk.is_some()).map(|cr| {
9165 let ck = nostr_sdk::prelude::nip44::v2::ConversationKey::derive(signer.secret_key(), member).unwrap();
9166 let payload = crate::community::cipher::encrypt_with_random_nonce(&ck, super::rekey::control_wrap_b64(community.root_epoch, &cr).as_bytes()).unwrap();
9167 base64_simd::STANDARD.encode_to_string(&payload)
9168 });
9169 let content = crate::community::v2::roles::grant_content_json_with_wrap(&grant, control_wrap).unwrap();
9170 let cite = cite_on_relay(relay, community, signer).await;
9171 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
9172 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
9173 relay.publish(&wrap, &community.relays).await.unwrap();
9174 }
9175
9176 async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
9178 let group = control::ControlPlane::of(&community).write_group().unwrap();
9179 let eid = crate::community::v2::derive::banlist_locator(community.id());
9180 let prev = head_hash_on_relay(relay, community, &eid).await;
9181 let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
9182 let cite = cite_on_relay(relay, community, signer).await;
9183 let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
9184 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
9185 relay.publish(&wrap, &community.relays).await.unwrap();
9186 }
9187
9188 fn admin_role(role_id: &str, perms: u64) -> Role {
9189 Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
9190 }
9191
9192 async fn create_legacy_community(relay: &MemoryRelay, owner: &Keys, name: &str, relays: Vec<String>) -> CommunityV2 {
9197 let identity = control::CommunityIdentity::mint(&owner.public_key());
9198 let root = crate::community::random_32();
9199 let group = control_group_key(&root, &identity.community_id, Epoch(0));
9200 let meta = control::CommunityMetadata { name: name.into(), relays: relays.clone(), ..Default::default() };
9201 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);
9202 let ch_id = ChannelId(crate::community::random_32());
9203 let ch = control::ChannelMetadata { name: "general".into(), private: false, ..Default::default() };
9204 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);
9205 for r in [&meta_rumor, &ch_rumor] {
9206 let (w, _) = control::seal_control_edition(r, &group, owner, Timestamp::from_secs(1_000)).unwrap();
9207 relay.publish(&w, &relays).await.unwrap();
9208 }
9209 let community = CommunityV2 {
9210 identity,
9211 community_root: root,
9212 root_epoch: Epoch(0),
9213 control_pk: None,
9214 control_root: None,
9215 name: name.into(),
9216 description: None,
9217 icon: None,
9218 banner: None,
9219 meta_custom: None,
9220 meta_extra: Default::default(),
9221 relays,
9222 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() }],
9223 dissolved: false,
9224 created_at_ms: 1_000_000,
9225 };
9226 crate::db::community::save_community_v2(&community).unwrap();
9227 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9228 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &root);
9229 let _ = follow_control(relay, &community).await;
9230 crate::db::community::load_community_v2(community.id()).unwrap().unwrap()
9231 }
9232
9233 #[tokio::test]
9234 async fn a_refounding_mints_the_split_and_delivers_the_pair_by_staffness() {
9235 let (bed, owner, admin) = TestBed::new();
9240 bed.swap_to(&owner);
9241 let community = create_community(&bed.relay, "SplitRefound", bed.relays.clone(), None).await.unwrap();
9242 assert!(community.control_pk.is_some() && community.control_root.is_some(), "genesis mints the split");
9243
9244 let rid = "d1".repeat(32);
9247 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9248 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
9249 let plain = Keys::generate();
9250 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
9251 let join = guestbook::build_join_rumor(plain.public_key(), None, 1_000);
9252 let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &plain, Timestamp::from_secs(1_000)).unwrap();
9253 bed.relay.publish(&w, &community.relays).await.unwrap();
9254
9255 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
9257 bed.swap_to(&admin);
9258 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9259 assert_eq!(joined.control_pk, community.control_pk, "the bundle handed over the address");
9260 assert_eq!(joined.control_root, None, "a bundle never carries the secret");
9261 let _ = follow_control(&bed.relay, &joined).await;
9262 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
9263 assert_eq!(joined.control_root, community.control_root, "the Grant's control_wrap delivered the secret");
9264
9265 bed.swap_to(&owner);
9267 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
9268 assert_eq!(refounded.root_epoch, Epoch(1));
9269 assert!(refounded.control_pk.is_some() && refounded.control_root.is_some());
9270 assert_ne!(refounded.control_pk, community.control_pk, "the pair rolls with the root");
9271
9272 let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
9275 let chunks = fetch_rekey_chunks(&bed.relay, &community.relays, &base_group).await.unwrap();
9276 let rotation = &rekey::collect_rotations(&chunks)[0];
9277 async fn open_as(rotation: &rekey::Rotation, community_id: &crate::community::CommunityId, keys: &Keys) -> rekey::BaseKeyDelivery {
9278 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");
9279 let signer = crate::signer::ActiveSigner::Keys(keys.clone());
9280 rekey::open_base_blob(&signer, &rotation.rotator, community_id, Epoch(1), blob).await.unwrap()
9281 }
9282 let admin_delivery = open_as(rotation, community.id(), &admin.keys).await;
9283 assert_eq!(admin_delivery.control_root, refounded.control_root, "staff blob = 136, secret included");
9284 let plain_delivery = open_as(rotation, community.id(), &plain).await;
9285 assert_eq!(plain_delivery.control_pk.map(|p| PublicKey::from_slice(p.as_slice()).unwrap()), refounded.control_pk);
9286 assert_eq!(plain_delivery.control_root, None, "member blob = 104, never the secret");
9287
9288 let legacy_new = control_group_key(&refounded.community_root, community.id(), Epoch(1));
9291 let q = |pk: String| Query { kinds: vec![stream::KIND_WRAP], authors: vec![pk], limit: Some(50), ..Default::default() };
9292 assert!(bed.relay.fetch(&q(legacy_new.pk_hex()), &community.relays).await.unwrap().is_empty(), "no legacy mirror");
9293 assert!(!bed.relay.fetch(&q(refounded.control_pk.unwrap().to_hex()), &community.relays).await.unwrap().is_empty(), "the compaction rides the split address");
9294
9295 bed.swap_to(&admin);
9297 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
9298 let adopted = follow.updated.expect("the admin adopts the rotation");
9299 assert_eq!(adopted.root_epoch, Epoch(1));
9300 assert_eq!(adopted.control_pk, refounded.control_pk);
9301 assert_eq!(adopted.control_root, refounded.control_root, "staff crossing a rotation get the new secret in the blob");
9302 }
9303
9304 #[tokio::test]
9305 async fn revoking_the_last_link_privatizes_and_rotates_but_an_earlier_revoke_does_not() {
9306 let (_tmp, _guard, _owner) = init_test_db();
9312 let relay = MemoryRelay::new();
9313 let community = create_community(&relay, "Privatize", vec!["wss://r".into()], None).await.unwrap();
9314 assert_eq!(community.root_epoch, Epoch(0));
9315
9316 let a = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
9317 let b = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
9318 assert!(community_is_public(&relay, &community).await, "two live links = Public");
9319
9320 revoke_public_link(&relay, &community, &crate::simd::hex::bytes_to_hex_16(&a.token)).await.unwrap();
9322 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9323 assert_eq!(held.root_epoch, Epoch(0), "a link remains live — not the Public→Private conversion");
9324 assert!(community_is_public(&relay, &held).await, "still Public");
9325
9326 revoke_public_link(&relay, &held, &crate::simd::hex::bytes_to_hex_16(&b.token)).await.unwrap();
9328 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9329 assert!(!community_is_public(&relay, &held).await, "no live links = Private");
9330 assert_eq!(held.root_epoch, Epoch(1), "privatizing rotated the base key");
9331 assert!(held.control_pk.is_some(), "the privatize rotation mints the split like any base rotation");
9334 assert_ne!(held.community_root, community.community_root, "the base key actually rolled");
9335 }
9336
9337 #[tokio::test]
9338 async fn a_legacy_community_upgrades_as_a_side_effect_of_its_next_refounding() {
9339 let (_tmp, _guard, owner) = init_test_db();
9342 let relay = MemoryRelay::new();
9343 let community = create_legacy_community(&relay, &owner, "Legacy", vec!["wss://r".into()]).await;
9344 assert!(community.control_pk.is_none(), "starts pre-split");
9345
9346 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
9347 assert_eq!(refounded.root_epoch, Epoch(1));
9348 assert!(refounded.control_pk.is_some() && refounded.control_root.is_some(), "the rotation minted the split");
9349 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9351 assert_eq!(reloaded.control_pk, refounded.control_pk);
9352 assert_eq!(reloaded.control_root, refounded.control_root);
9353 }
9354
9355 #[tokio::test]
9356 async fn an_old_build_deletion_recovers_from_the_stale_bundle_across_the_split() {
9357 let (bed, owner, member) = TestBed::new();
9363 bed.swap_to(&owner);
9364 let community = create_legacy_community(&bed.relay, &owner.keys, "Recover", bed.relays.clone()).await;
9365 let stale_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
9366
9367 bed.swap_to(&member);
9369 let joined = accept_parked_invite(&bed.relay, &stale_bundle, None).await.unwrap();
9370 assert!(joined.control_pk.is_none());
9371
9372 bed.swap_to(&owner);
9374 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
9375
9376 bed.swap_to(&member);
9378 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9379 crate::db::community::delete_community(&cid_hex).unwrap();
9380 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
9381
9382 let readopted = accept_parked_invite(&bed.relay, &stale_bundle, None).await.unwrap();
9385 assert_eq!(readopted.root_epoch, Epoch(0), "re-anchored at the stale epoch");
9386 let follow = follow_rekeys(&bed.relay, &readopted, &crate::db::current_session()).await.unwrap();
9388 assert!(!follow.self_removed, "never a false removal on this build");
9389 let healed = follow.updated.expect("the walk adopts the split rotation");
9390 assert_eq!(healed.root_epoch, Epoch(1));
9391 assert_eq!(healed.control_pk, refounded.control_pk, "the member reads the upgraded plane");
9392 assert_eq!(healed.control_root, None, "a plain member never receives the secret");
9393 let folded = follow_control(&bed.relay, &healed).await;
9395 assert!(folded.is_ok(), "the recovered member folds the split plane: {folded:?}");
9396 }
9397
9398 #[tokio::test]
9399 async fn an_unopenable_blob_at_my_locator_never_concludes_removal() {
9400 let (bed, owner, member) = TestBed::new();
9405 bed.swap_to(&owner);
9406 let community = create_community(&bed.relay, "Garbled", bed.relays.clone(), None).await.unwrap();
9407 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
9408 bed.swap_to(&member);
9409 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9410
9411 bed.swap_to(&owner);
9414 let new_epoch = Epoch(1);
9415 let new_root = [0x4Du8; 32];
9416 let control_root = [0x4Eu8; 32];
9417 let control_pk = super::super::derive::control_signer_group_key(&control_root, community.id(), new_epoch).pk();
9418 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
9419 let garbled = rekey::RekeyBlob {
9420 locator: rekey::blob_locator(&owner.keys.public_key().to_bytes(), &member.keys.public_key().to_bytes(), RekeyScope::Root, new_epoch),
9421 wrapped: "bm90LWEtcmVhbC1uaXA0NC1wYXlsb2Fk".into(),
9422 };
9423 let own = rekey::build_base_blob(
9424 &crate::signer::ActiveSigner::Keys(owner.keys.clone()),
9425 &owner.keys.public_key().to_bytes(),
9426 &owner.keys.public_key(),
9427 new_epoch,
9428 &new_root,
9429 &control_pk.to_bytes(),
9430 Some(&control_root),
9431 )
9432 .await
9433 .unwrap();
9434 let base_group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
9435 let chunks = rekey::build_rekey_chunks_local(&owner.keys, &base_group, RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[garbled, own], 2_000, None).unwrap();
9436 for c in &chunks {
9437 bed.relay.publish(c, &community.relays).await.unwrap();
9438 }
9439
9440 bed.swap_to(&member);
9442 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
9443 assert!(!follow.self_removed, "an unopenable blob at my locator is not an exclusion");
9444 assert!(follow.updated.is_none(), "nothing adopted from an unreadable blob");
9445 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "the community survives");
9446 assert!(follow.wedged, "an unopenable key delivery must be surfaced, not swallowed");
9450 }
9451
9452 #[tokio::test]
9453 async fn a_control_wrap_is_refused_on_a_stale_epoch_or_a_non_deriving_secret() {
9454 let (bed, owner, member) = TestBed::new();
9463 bed.swap_to(&owner);
9464 let community = create_community(&bed.relay, "AdoptGate", bed.relays.clone(), None).await.unwrap();
9465 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
9466 bed.swap_to(&member);
9467 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9468 assert!(joined.control_root.is_none());
9469
9470 let wrap_for = |epoch: Epoch, secret: &[u8; 32]| {
9472 let ck = nostr_sdk::prelude::nip44::v2::ConversationKey::derive(owner.keys.secret_key(), &member.keys.public_key()).unwrap();
9473 let ct = crate::community::cipher::encrypt_with_random_nonce(&ck, super::rekey::control_wrap_b64(epoch, secret).as_bytes()).unwrap();
9474 base64_simd::STANDARD.encode_to_string(&ct)
9475 };
9476 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.keys.public_key().to_bytes());
9477 let ed_with = |wrap: String| {
9478 let grant = MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![] };
9479 let content = crate::community::v2::roles::grant_content_json_with_wrap(&grant, Some(wrap)).unwrap();
9480 let rumor = control::build_edition_rumor(owner.keys.public_key(), vsk::GRANT, &eid, 1, None, &content, 1_000, None);
9481 control::parse_edition_rumor(&rumor).unwrap()
9482 };
9483
9484 let real = community.control_root.unwrap();
9486 let stale = ed_with(wrap_for(Epoch(joined.root_epoch.0 + 1), &real));
9487 assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&stale)).await, None, "a stale-epoch wrap is refused");
9488
9489 let forged = ed_with(wrap_for(joined.root_epoch, &[0x66u8; 32]));
9491 assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&forged)).await, None, "a non-deriving secret is refused");
9492
9493 let good = ed_with(wrap_for(joined.root_epoch, &real));
9496 assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&good)).await, Some(real), "the true wrap adopts");
9497 }
9498
9499 #[test]
9500 fn the_vault_carries_the_split_and_the_twin_strips_the_secret() {
9501 let owner = Keys::generate();
9505 let g = control::genesis(&owner, control::CommunityMetadata { name: "Vault".into(), ..Default::default() }, 1_000).unwrap();
9506 let c = CommunityV2::from_genesis(&g, "Vault", None, vec!["wss://r".into()], 0);
9507 let jm = join_material(&c);
9508 assert_eq!(jm.control_pk, c.control_pk.map(|p| p.to_hex()));
9509 assert_eq!(jm.control_root, Some(crate::simd::hex::bytes_to_hex_32(&g.control_root)));
9510 let twin = twin_join_material(&c);
9511 assert_eq!(twin.control_pk, jm.control_pk, "members get the address");
9512 assert_eq!(twin.control_root, None, "members never get the secret");
9513 let bundle = material_to_invite(&jm);
9514 assert_eq!(bundle.control_pk, jm.control_pk, "a rehydrating bundle carries the address");
9515 }
9516
9517 #[tokio::test]
9523 async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
9524 let (bed, owner, attacker) = TestBed::new();
9525 bed.swap_to(&owner);
9526 let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
9527 let victim = Keys::generate().public_key();
9528 grant_admin(&bed.relay, &community, &victim).await.unwrap();
9529
9530 let admin_rid = fetch_authority(&bed.relay, &community)
9532 .await
9533 .roles
9534 .roles
9535 .iter()
9536 .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
9537 .unwrap()
9538 .role_id
9539 .clone();
9540 publish_role(
9542 &bed.relay,
9543 &community,
9544 &attacker.keys,
9545 &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
9546 2,
9547 )
9548 .await;
9549
9550 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
9551 assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
9552 assert!(
9553 authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
9554 "the floor advances only to the AUTHORIZED head (owner v1)"
9555 );
9556 assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
9557 }
9558
9559 #[tokio::test]
9560 async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
9561 let (bed, owner, attacker) = TestBed::new();
9562 bed.swap_to(&owner);
9563 let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
9564 let victim = Keys::generate();
9565 grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
9566
9567 publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
9569
9570 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
9571 assert!(
9572 authority.roles.is_admin(&victim.public_key().to_hex()),
9573 "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
9574 );
9575 }
9576
9577 #[tokio::test]
9578 async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
9579 let (bed, owner, attacker) = TestBed::new();
9580 bed.swap_to(&owner);
9581 let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
9582 let victim = Keys::generate().public_key();
9583 grant_admin(&bed.relay, &community, &victim).await.unwrap();
9584
9585 for i in 0u8..6 {
9588 let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
9589 publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9590 }
9591
9592 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
9593 assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
9594 assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
9595 }
9596
9597 fn authority_fingerprint(a: &AuthoritySet) -> String {
9600 let mut roles = a.roles.roles.clone();
9601 roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
9602 let mut grants = a.roles.grants.clone();
9603 for g in &mut grants {
9604 g.role_ids.sort();
9605 }
9606 grants.sort_by(|x, y| x.member.cmp(&y.member));
9607 let banned: Vec<&String> = a.banned.iter().collect();
9608 serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
9609 }
9610
9611 #[tokio::test]
9612 async fn the_v2_authority_fold_is_order_independent() {
9613 let (bed, owner, _a) = TestBed::new();
9619 bed.swap_to(&owner);
9620 let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
9621
9622 let admin1 = Keys::generate().public_key();
9625 let admin2 = Keys::generate().public_key();
9626 grant_admin(&bed.relay, &community, &admin1).await.unwrap();
9627 grant_admin(&bed.relay, &community, &admin2).await.unwrap();
9628 let mod_rid = "5c".repeat(32);
9629 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
9630 let member = Keys::generate().public_key();
9631 publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
9632 let banned_member = Keys::generate().public_key();
9633 publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
9634 set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
9635 let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
9636 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
9637 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
9638
9639 let editions = fetch_control(&bed.relay, &community).await;
9640 let floors = load_floors(&community);
9641 assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
9642
9643 let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
9644
9645 let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
9648 let mut rev = editions.clone();
9649 rev.reverse();
9650 orders.push(rev);
9651 for shift in [1usize, 3, 5, 7] {
9652 let n = editions.len();
9653 orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
9654 }
9655 let mut zip = Vec::with_capacity(editions.len());
9657 let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
9658 while lo <= hi {
9659 zip.push(editions[lo as usize].clone());
9660 if lo != hi {
9661 zip.push(editions[hi as usize].clone());
9662 }
9663 lo += 1;
9664 hi -= 1;
9665 }
9666 orders.push(zip);
9667
9668 for (i, order) in orders.iter().enumerate() {
9669 let got = authority_fingerprint(&fold_authority(&community, order, &floors));
9670 assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
9671 }
9672 assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
9675 assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
9676 }
9677
9678 struct FetchErrors(MemoryRelay);
9680 #[async_trait::async_trait]
9681 impl crate::community::transport::Transport for FetchErrors {
9682 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
9683 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
9684 self.0.publish(e, r).await
9685 }
9686 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
9687 Err("relay down".to_string())
9688 }
9689 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
9690 self.0.publish_durable(e, r).await
9691 }
9692 }
9693
9694 #[tokio::test]
9695 async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
9696 let (bed, owner, victim) = TestBed::new();
9697 bed.swap_to(&owner);
9698 let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
9699 let victim_hex = victim.keys.public_key().to_hex();
9700 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9702 crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
9703
9704 let down = FetchErrors(MemoryRelay::new());
9707 let view = fetch_authority(&down, &community).await;
9708 assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
9709 }
9710
9711 #[tokio::test]
9712 async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
9713 let (bed, owner, _m) = TestBed::new();
9714 bed.swap_to(&owner);
9715 let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
9716 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9717 let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
9718 let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
9719
9720 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9722 publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
9723 publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
9724 follow_control(&bed.relay, &community).await.unwrap();
9725 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
9726
9727 let relay2 = MemoryRelay::new();
9731 publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
9732 follow_control(&relay2, &community).await.unwrap();
9733 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
9734 assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
9735 }
9736
9737 #[tokio::test]
9738 async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
9739 let (_tmp, _guard, owner) = init_test_db();
9744 let relay = MemoryRelay::new();
9745 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
9746 let admin = Keys::generate();
9747 let rid = "a7".repeat(32);
9748 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
9749 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
9750
9751 let group = control::ControlPlane::of(&community).write_group().unwrap();
9753 let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
9754 let rumor = control::build_edition_rumor(
9755 admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
9756 head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
9757 &serde_json::to_string(&meta).unwrap(), 1_000, None,
9758 );
9759 let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
9760 relay.publish(&wrap, &community.relays).await.unwrap();
9761
9762 let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
9763 let victim = Keys::generate().public_key().to_hex();
9764 let ban_rumor = control::build_edition_rumor(
9765 admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
9766 &serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
9767 );
9768 let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
9769 relay.publish(&ban_wrap, &community.relays).await.unwrap();
9770
9771 let updated = follow_control(&relay, &community).await.unwrap();
9772 assert!(
9773 updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
9774 "an uncited metadata edit must not be honored",
9775 );
9776 let authority = fetch_authority(&relay, &community).await;
9777 assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
9778 }
9785
9786 #[tokio::test]
9787 async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
9788 let (_tmp, _guard, owner) = init_test_db();
9792 let relay = MemoryRelay::new();
9793 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
9794 let admin = Keys::generate();
9795 let rid = "a1".repeat(32);
9796 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
9797 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
9798 publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
9799
9800 let updated = follow_control(&relay, &community).await.unwrap().expect("admin edit authorized");
9801 assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
9802
9803 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
9805 let _ = follow_control(&relay, &community).await.unwrap();
9806 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9807 assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
9808 }
9809
9810 #[tokio::test]
9811 async fn a_roleless_member_cannot_edit_metadata() {
9812 let (_tmp, _guard, _owner) = init_test_db();
9813 let relay = MemoryRelay::new();
9814 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
9815 let stranger = Keys::generate();
9816 publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
9817 assert!(
9818 follow_control(&relay, &community).await.unwrap().is_none(),
9819 "a roleless member's metadata edit never folds"
9820 );
9821 }
9822
9823 #[tokio::test]
9824 async fn a_self_signed_grant_is_not_authority() {
9825 let (_tmp, _guard, _owner) = init_test_db();
9829 let relay = MemoryRelay::new();
9830 let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
9831 let rogue = Keys::generate();
9832 let rid = "b2".repeat(32);
9833 publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9834 publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
9835 publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
9836 assert!(
9837 follow_control(&relay, &community).await.unwrap().is_none(),
9838 "a self-signed grant confers no authority"
9839 );
9840 }
9841
9842 #[tokio::test]
9843 async fn the_banlist_is_enforced_only_from_a_ban_holder() {
9844 let (_tmp, _guard, owner) = init_test_db();
9845 let relay = MemoryRelay::new();
9846 let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
9847 let target = "cc".repeat(32);
9848
9849 let rogue = Keys::generate();
9851 publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
9852 let floors = load_floors(&community);
9853 let editions = fetch_control(&relay, &community).await;
9854 let authority = fold_authority(&community, &editions, &floors);
9855 assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
9856
9857 publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
9859 let editions = fetch_control(&relay, &community).await;
9860 let authority = fold_authority(&community, &editions, &floors);
9861 assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
9862 }
9863
9864 #[tokio::test]
9865 async fn a_banned_admin_loses_all_authority() {
9866 let (_tmp, _guard, owner) = init_test_db();
9869 let relay = MemoryRelay::new();
9870 let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
9871 let admin = Keys::generate();
9872 let rid = "e5".repeat(32);
9873 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
9874 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
9875 publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
9877
9878 assert!(
9879 follow_control(&relay, &community).await.unwrap().is_none(),
9880 "a banned admin's edit is dropped even with an unstripped grant"
9881 );
9882 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
9883 assert!(authority.banned.contains(&admin.public_key().to_hex()));
9884 assert!(
9885 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
9886 "a banned admin holds no bit"
9887 );
9888 }
9889
9890 #[tokio::test]
9891 async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
9892 let (_tmp, _guard, owner) = init_test_db();
9896 let relay = MemoryRelay::new();
9897 let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
9898 let admin = Keys::generate();
9899 let moder = Keys::generate();
9900 let stranger = Keys::generate();
9901 let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
9902 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;
9903 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;
9904 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
9905 publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
9906 publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
9907
9908 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
9909 assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
9910 assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
9911 assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
9912 }
9913
9914 #[tokio::test]
9915 async fn an_unauthorized_higher_banlist_cannot_unban() {
9916 let (_tmp, _guard, owner) = init_test_db();
9920 let relay = MemoryRelay::new();
9921 let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
9922 let target = "cc".repeat(32);
9923 publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
9924 follow_control(&relay, &community).await.unwrap(); let rogue = Keys::generate();
9927 publish_banlist(&relay, &community, &rogue, &[], 2).await; let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
9929 assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
9930 }
9931
9932 #[tokio::test]
9933 async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
9934 let (_tmp, _guard, _owner) = init_test_db();
9937 let relay = MemoryRelay::new();
9938 let relays = vec!["wss://r".to_string()];
9939 let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
9940 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
9941 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
9942
9943 let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
9944 assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
9945 assert_eq!(rehydrated[0].id().0, community.id().0);
9946 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
9947 }
9948
9949 #[tokio::test]
9950 async fn a_retried_leave_tombstone_cannot_bury_a_rejoin_made_meanwhile() {
9951 let (_tmp, _guard, _owner) = init_test_db();
9952 let relay = MemoryRelay::new();
9953 let relays = vec!["wss://r".to_string()];
9954 let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
9955 let cid = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9956
9957 let left_at = now_ms() - 1000;
9959 tombstone_community_list(&relay, community.id(), &relays, left_at).await.unwrap();
9960 republish_community_list(&relay, Some(community.id())).await.unwrap();
9962 assert!(fetch_fragments(&relay, &relays).await.unwrap().unwrap().list.is_live(&cid));
9963
9964 tombstone_community_list(&relay, community.id(), &relays, left_at).await.unwrap();
9966 let set = fetch_fragments(&relay, &relays).await.unwrap().unwrap();
9967 assert!(set.list.is_live(&cid), "a retry re-stamping `now` would have buried the rejoin");
9968 }
9969
9970 #[tokio::test]
9971 async fn a_boot_with_no_fragments_seeds_the_list_from_local_state() {
9972 let (_tmp, _guard, _owner) = init_test_db();
9973 let relay = MemoryRelay::new();
9974 let relays = vec!["wss://r".to_string()];
9975 let community = create_community(&relay, "Seeded", relays.clone(), None).await.unwrap();
9976
9977 let bare = MemoryRelay::new();
9980 sync_community_list(&bare, &relays).await.unwrap();
9981 let set = fetch_fragments(&bare, &relays).await.unwrap().expect("boot seeded the List");
9982 let live = set.list.live_entries();
9983 assert_eq!(live.len(), 1);
9984 assert_eq!(live[0].community_id, crate::simd::hex::bytes_to_hex_32(&community.id().0));
9985
9986 let empty = MemoryRelay::new();
9989 sync_community_list(&empty, &relays).await.unwrap();
9990 assert_eq!(empty.stored_count(), 0, "the seed runs once");
9991 }
9992
9993 #[tokio::test]
9994 async fn a_dissolved_invite_retires_on_accept_and_tombstones_the_list() {
9995 let (bed, owner, member) = TestBed::new();
9999 bed.swap_to(&owner);
10000 let community = create_community(&bed.relay, "Grave", bed.relays.clone(), None).await.unwrap();
10001 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
10002 dissolve_community(&bed.relay, &community).await.unwrap();
10003
10004 bed.swap_to(&member);
10005 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10006 crate::db::community::save_pending_invite(&cid_hex, &bundle, "inviter", i64::MAX).unwrap();
10007 let err = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap_err();
10008 assert_eq!(err, ERR_DISSOLVED, "the grave refusal is the sentinel the boundary matches");
10009
10010 retire_dead_invite(&bed.relay, &cid_hex, &bed.relays).await;
10011 assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_none(), "the dead invite leaves this device");
10012 let set = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("the tombstone published");
10013 assert!(set.list.tombstones.iter().any(|t| t.community_id == cid_hex), "and siblings get the §8 tombstone");
10014 assert!(!set.list.is_live(&cid_hex));
10015 }
10016
10017 #[tokio::test]
10018 async fn an_owner_dissolve_tombstones_the_list_for_sibling_devices() {
10019 let (bed, owner, _member) = TestBed::new();
10024 bed.swap_to(&owner);
10025 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
10026 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10027 let before = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("create published the List");
10028 assert!(before.list.is_live(&cid_hex), "the membership is live before the dissolve");
10029
10030 dissolve_community(&bed.relay, &community).await.unwrap();
10031
10032 let set = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("the List outlives the dissolve");
10033 assert!(
10034 set.list.tombstones.iter().any(|t| t.community_id == cid_hex),
10035 "the dissolve published the §8 tombstone alongside the grave"
10036 );
10037 assert!(!set.list.is_live(&cid_hex), "sibling devices tear the husk down on their next list sync");
10038 }
10039
10040 #[tokio::test]
10041 async fn a_sibling_devices_list_tombstone_purges_the_parked_invite() {
10042 let (_tmp, _guard, _owner) = init_test_db();
10046 let relay = MemoryRelay::new();
10047 let relays = vec!["wss://r".to_string()];
10048 let cid = crate::community::CommunityId([0x5A; 32]);
10049 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
10050
10051 tombstone_community_list(&relay, &cid, &relays, now_ms() - 600_000).await.unwrap();
10053 crate::db::community::save_pending_invite(&cid_hex, "{}", "inviter", i64::MAX).unwrap();
10054 sync_community_list(&relay, &relays).await.unwrap();
10055 assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_some(), "a re-invite supersedes a past leave");
10056
10057 tombstone_community_list(&relay, &cid, &relays, now_ms() + 10_000).await.unwrap();
10059 sync_community_list(&relay, &relays).await.unwrap();
10060 assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_none(), "the sibling's removal clears the parked copy");
10061 }
10062
10063 #[tokio::test]
10064 async fn an_unchanged_republish_rewrites_no_fragment() {
10065 let (_tmp, _guard, _owner) = init_test_db();
10069 let relay = MemoryRelay::new();
10070 let relays = vec!["wss://r".to_string()];
10071 create_community(&relay, "Stable", relays.clone(), None).await.unwrap();
10072 let before = fetch_fragments(&relay, &relays).await.unwrap().unwrap().created_at;
10073
10074 republish_community_list(&relay, None).await.unwrap();
10075 let after = fetch_fragments(&relay, &relays).await.unwrap().unwrap().created_at;
10076 assert_eq!(before, after, "a byte-identical fragment must not republish");
10077 }
10078
10079 #[tokio::test]
10080 async fn a_frags_tie_at_equal_age_resolves_to_the_larger_count() {
10081 let (_tmp, _guard, owner) = init_test_db();
10085 let relay = MemoryRelay::new();
10086 let relays = vec!["wss://r".to_string()];
10087 let at = now_ms() / 1000;
10088 let two = super::super::list_frag::FragList { frags: 2, entries: vec![], tombstones: vec![], extra: Default::default() };
10089 let three = super::super::list_frag::FragList { frags: 3, entries: vec![], tombstones: vec![], extra: Default::default() };
10090 for (frag, index) in [(&three, 0usize), (&two, 1usize)] {
10093 let e = super::super::list_frag::build_fragment_event_keys(&owner, frag, index, at).unwrap();
10094 relay.publish(&e, &relays).await.unwrap();
10095 }
10096 let set = fetch_fragments(&relay, &relays).await.unwrap().unwrap();
10097 assert_eq!(set.declared, 3, "an age tie resolves to the larger frags");
10098 assert!(!set.is_complete(), "index 2 is unread, so the set refuses to write");
10099 }
10100
10101 struct HalfDeaf<'a>(&'a MemoryRelay, String);
10104 #[async_trait::async_trait]
10105 impl Transport for HalfDeaf<'_> {
10106 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
10107 self.0.publish(e, r).await
10108 }
10109 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
10110 if relays.len() == 1 && relays[0] == self.1 {
10111 return Err("relay did not answer the fetch".to_string());
10112 }
10113 self.0.fetch(query, relays).await
10114 }
10115 async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
10116 self.0.fetch_plane(plane, query, relays).await
10117 }
10118 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
10119 self.0.publish_durable(e, r).await
10120 }
10121 }
10122
10123 #[tokio::test]
10124 async fn an_unconfirmed_empty_read_never_seeds() {
10125 let (_tmp, _guard, _owner) = init_test_db();
10131 let relay = MemoryRelay::new();
10132 let relays = vec!["wss://r".to_string(), "wss://dead".to_string()];
10133 let community = create_community(&relay, "Gated", relays.clone(), None).await.unwrap();
10134 let cid = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10135
10136 let bare = MemoryRelay::new();
10137 let half_deaf = HalfDeaf(&bare, "wss://dead".to_string());
10138 sync_community_list(&half_deaf, &relays).await.unwrap();
10139 assert_eq!(bare.stored_count(), 0, "a failed read must never seed");
10140 assert!(
10141 crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).unwrap().is_none(),
10142 "a deferred seed must not latch"
10143 );
10144
10145 sync_community_list(&bare, &relays).await.unwrap();
10147 let set = fetch_fragments(&bare, &relays).await.unwrap().expect("the confirmed-empty boot seeds");
10148 assert!(set.list.is_live(&cid));
10149 assert!(crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).unwrap().is_some());
10150 }
10151
10152 #[tokio::test]
10153 async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
10154 let (_tmp, _guard, _owner) = init_test_db();
10155 let relay = MemoryRelay::new();
10156 let relays = vec!["wss://r".to_string()];
10157 let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
10158 leave_community(&relay, &community).await.unwrap(); let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
10161 assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
10162 }
10163
10164 #[tokio::test]
10165 async fn accepting_the_same_bundle_twice_is_idempotent() {
10166 let (bed, owner, member) = TestBed::new();
10170 bed.swap_to(&owner);
10171 let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
10172 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
10173 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10174 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
10175
10176 bed.swap_to(&member);
10177 let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
10178 let channels_after_first = first.channels.len();
10179 let root_after_first = first.community_root;
10180
10181 let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
10183 assert_eq!(second.id().0, first.id().0, "same community_id");
10184 assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
10185 assert_eq!(second.community_root, root_after_first, "root unchanged");
10186
10187 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10189 assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
10190 assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
10191 }
10192
10193 #[tokio::test]
10194 async fn a_severed_member_can_be_unbanned_and_re_admitted() {
10195 let (bed, owner, member) = TestBed::new();
10200 bed.swap_to(&owner);
10201 let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
10202 let general = community.channels[0].id;
10203 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
10204 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
10205
10206 bed.swap_to(&member);
10207 let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10208 let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
10209 assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
10210
10211 bed.swap_to(&owner);
10213 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
10214 grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
10215 community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
10216 assert_eq!(community.root_epoch, Epoch(1));
10217 send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
10218
10219 bed.swap_to(&member);
10221 let session = crate::db::current_session();
10222 assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
10223
10224 bed.swap_to(&owner);
10228 set_banlist(&bed.relay, &community, &[]).await.unwrap();
10229 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10230 assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
10231 let fresh_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
10232
10233 bed.swap_to(&member);
10235 let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
10236 assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
10237 assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
10238 let seen = texts_in(&bed.relay, &rejoined, &general).await;
10239 assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
10240 send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
10241
10242 bed.swap_to(&owner);
10243 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10244 assert!(
10245 texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
10246 "the re-admitted member converses again at the new epoch"
10247 );
10248 let members = memberlist(&bed.relay, &community).await.unwrap();
10250 assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
10251 }
10252
10253 #[tokio::test]
10254 async fn dissolution_blocks_a_join() {
10255 let (bed, owner, member) = TestBed::new();
10258 bed.swap_to(&owner);
10259 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
10260 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10261 let bundle_json = serde_json::to_string(&bundle).unwrap();
10262 dissolve_community(&bed.relay, &community).await.unwrap();
10263 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
10264
10265 bed.swap_to(&member);
10266 let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
10267 assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
10268 }
10269
10270 #[tokio::test]
10271 async fn dissolution_seals_writes_but_not_reads() {
10272 let (bed, owner, _member) = TestBed::new();
10275 bed.swap_to(&owner);
10276 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
10277 let general = community.channels[0].id;
10278 send_message(&bed.relay, &community, &general, "before the end").await.unwrap();
10279
10280 dissolve_community(&bed.relay, &community).await.unwrap();
10281 let sealed = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10282
10283 for err in [
10284 send_message(&bed.relay, &sealed, &general, "after the end").await.unwrap_err(),
10285 send_reaction(&bed.relay, &sealed, &general, &"a".repeat(64), &"b".repeat(64), crate::community::v2::kind::MESSAGE, "+", None)
10286 .await
10287 .unwrap_err(),
10288 send_edit(&bed.relay, &sealed, &general, &"a".repeat(64), "revised").await.unwrap_err(),
10289 ] {
10290 assert!(err.contains("dissolved"), "every write is refused, got: {err}");
10291 }
10292 assert!(
10293 texts_in(&bed.relay, &sealed, &general).await.contains(&"before the end".to_string()),
10294 "but the history still reads"
10295 );
10296 }
10297
10298 #[tokio::test]
10299 async fn only_the_owner_can_dissolve() {
10300 let (bed, owner, member) = TestBed::new();
10301 bed.swap_to(&owner);
10302 let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
10303 bed.swap_to(&member);
10304 assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
10305 assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
10306 }
10307
10308 #[tokio::test]
10309 async fn a_foreign_tombstone_is_not_death() {
10310 let (_tmp, _guard, _owner) = init_test_db();
10313 let relay = MemoryRelay::new();
10314 let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
10315 let rogue = Keys::generate();
10316 let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
10317 let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
10318 relay.publish(&wrap, &community.relays).await.unwrap();
10319 assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
10320 }
10321
10322 #[tokio::test]
10323 async fn a_public_channel_reads_history_across_a_refounding() {
10324 let (_tmp, _guard, _owner) = init_test_db();
10328 let relay = MemoryRelay::new();
10329 let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
10330 let general = community.channels[0].id;
10331 send_message(&relay, &community, &general, "before the refounding").await.unwrap();
10332
10333 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
10334 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
10335 send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
10336
10337 let open = texts_in(&relay, &refounded, &general).await;
10338 assert!(open.contains(&"after the refounding".to_string()), "opening reads the live epoch");
10339 assert!(
10340 !open.contains(&"before the refounding".to_string()),
10341 "and NOT the rotated-past one — that plane is the back-paging cursor's job"
10342 );
10343 let paged = all_texts_in(&relay, &refounded, &general).await;
10344 assert!(paged.contains(&"before the refounding".to_string()), "the epoch-0 message stays reachable by paging");
10345 assert!(paged.contains(&"after the refounding".to_string()), "alongside the epoch-1 one");
10346 }
10347
10348 #[tokio::test]
10349 async fn refounding_aborts_when_control_state_is_withheld() {
10350 let (_tmp, _guard, owner) = init_test_db();
10354 let relay = MemoryRelay::new();
10355 let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
10356 publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
10357 follow_control(&relay, &community).await.unwrap(); let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10362 moved.relays = vec!["wss://empty".into()];
10363 crate::db::community::save_community_v2(&moved).unwrap();
10364
10365 let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
10366 assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
10367 assert_eq!(
10368 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
10369 Epoch(0),
10370 "the epoch did NOT advance (zero published state)"
10371 );
10372 }
10373
10374 #[tokio::test]
10375 async fn refounding_rolls_the_root_and_severs_a_removed_member() {
10376 let (bed, owner, member) = TestBed::new();
10379 bed.swap_to(&owner);
10380 let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
10381 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10382 let bundle_json = serde_json::to_string(&bundle).unwrap();
10383 bed.swap_to(&member);
10384 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10385
10386 bed.swap_to(&owner);
10387 let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
10388 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
10389 assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
10390 assert_eq!(
10392 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
10393 Epoch(1),
10394 "the owner committed the new epoch"
10395 );
10396
10397 bed.swap_to(&member);
10401 let session = crate::db::current_session();
10402 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
10403 assert!(follow.self_removed, "the removed member is cut by the re-founding");
10404 }
10405
10406 #[tokio::test]
10407 async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
10408 let (bed, owner, member) = TestBed::new();
10413 bed.swap_to(&owner);
10414 let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
10415 let rid = "b0".repeat(32);
10416 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
10417 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
10418 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10419 let bundle_json = serde_json::to_string(&bundle).unwrap();
10420 bed.swap_to(&member);
10421 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10422 let _ = follow_control(&bed.relay, &joined).await;
10425 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10426 assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
10428 assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
10430 }
10431
10432 #[tokio::test]
10433 async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
10434 let (bed, owner, me) = TestBed::new();
10439 let admin = Keys::generate();
10440 bed.swap_to(&owner);
10441 let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
10442 let rid = "b0".repeat(32);
10443 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
10444 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
10445
10446 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10448 let bundle_json = serde_json::to_string(&bundle).unwrap();
10449 bed.swap_to(&me);
10450 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10451 let _ = follow_control(&bed.relay, &joined).await;
10452 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10453
10454 let new_root = [0xC7; 32];
10457 publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
10458
10459 let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
10460 .expect("an authorized admin's Refounding is adopted");
10461 assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
10462 assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
10463 }
10464
10465 #[tokio::test]
10466 async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
10467 let (bed, owner, me) = TestBed::new();
10475 bed.swap_to(&owner);
10476 let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
10477 let rid = "b1".repeat(32);
10478 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
10480 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
10481
10482 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10483 let bundle_json = serde_json::to_string(&bundle).unwrap();
10484 bed.swap_to(&me);
10485 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10486 let _ = follow_control(&bed.relay, &joined).await;
10487 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10488
10489 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
10491 let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
10492 assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
10493
10494 let new_root = [0xD4; 32];
10497 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
10498
10499 let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
10500 .expect("the owner's Refounding is adopted");
10501 assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
10502
10503 let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
10504 assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
10505 assert_eq!(
10506 crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
10507 new_root,
10508 "and the new root behind the same URL",
10509 );
10510 }
10511
10512 #[tokio::test]
10513 async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
10514 let (bed, owner, me) = TestBed::new();
10518 let admin = Keys::generate();
10519 bed.swap_to(&owner);
10520 let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
10521 let rid = "b0".repeat(32);
10522 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
10523 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
10524
10525 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10526 let bundle_json = serde_json::to_string(&bundle).unwrap();
10527 bed.swap_to(&me);
10528 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10529 let _ = follow_control(&bed.relay, &joined).await;
10530 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10531
10532 publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
10534 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
10535 assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
10536 }
10537
10538 #[tokio::test]
10539 async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
10540 let (bed, owner, me) = TestBed::new();
10544 let admin_a = Keys::generate();
10545 let admin_b = Keys::generate(); bed.swap_to(&owner);
10547 let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
10548 let rid = "b0".repeat(32);
10549 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
10550 publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
10552 publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
10553
10554 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10555 let bundle_json = serde_json::to_string(&bundle).unwrap();
10556 bed.swap_to(&me);
10557 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10558 let _ = follow_control(&bed.relay, &joined).await;
10559 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10560
10561 publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
10563
10564 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
10565 assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
10566 }
10567
10568 #[tokio::test]
10569 async fn a_retried_refounding_reuses_the_same_root() {
10570 let (_tmp, _guard, _owner) = init_test_db();
10573 let relay = MemoryRelay::new();
10574 let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
10575 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10576 let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
10577 let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
10578 assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
10579 }
10580
10581 #[tokio::test]
10582 async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
10583 let (bed, owner, attacker) = TestBed::new();
10590 bed.swap_to(&owner);
10591 let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
10592
10593 let senior = "a1".repeat(32);
10595 let mid = "a5".repeat(32);
10596 publish_role(&bed.relay, &community, &owner.keys,
10597 &Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
10598 publish_role(&bed.relay, &community, &owner.keys,
10599 &Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0 }, 1).await;
10600 publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
10601
10602 publish_role(&bed.relay, &community, &attacker.keys,
10604 &Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 2).await;
10605
10606 let authority = fetch_authority(&bed.relay, &community).await;
10607 let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
10608 assert_eq!(
10609 folded_senior.position, 1,
10610 "a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
10611 );
10612 }
10613
10614 #[tokio::test]
10615 async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
10616 let (bed, owner, admin) = TestBed::new();
10623 bed.swap_to(&owner);
10624 let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
10625 let rid = "c1".repeat(32);
10626 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
10627 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
10628
10629 let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
10631 edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
10632 let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
10633 .filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
10634 .max_by_key(|e| e.version).expect("the owner's metadata edition");
10635 assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
10636
10637 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10640 let bundle_json = serde_json::to_string(&bundle).unwrap();
10641 bed.swap_to(&admin);
10642 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10643 let _ = follow_control(&bed.relay, &joined).await;
10644 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10645 set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
10646
10647 let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
10648 .find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
10649 .expect("the admin's banlist edition");
10650 let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
10651 assert_eq!(
10652 cite.entity_id,
10653 crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
10654 "the citation must name the ACTOR'S OWN grant coordinate",
10655 );
10656 assert!(cite.version >= 1, "pinned to a real grant version");
10657 }
10658
10659 #[tokio::test]
10660 async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
10661 let (_tmp, _guard, _owner) = init_test_db();
10667 let relay = MemoryRelay::new();
10668 let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
10669
10670 let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
10671 let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
10672 edit_community_metadata(&relay, &community, &meta).await.unwrap();
10673
10674 let updated = follow_control(&relay, &community).await.unwrap()
10675 .expect("the metadata edition is folded");
10676 assert_eq!(
10677 updated.relays.len(),
10678 crate::community::MAX_COMMUNITY_RELAYS,
10679 "a folded relay list must be truncated, never adopted whole",
10680 );
10681
10682 let again = follow_control(&relay, &updated).await.unwrap();
10686 assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
10687 }
10688
10689 #[tokio::test]
10690 async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
10691 let (bed, owner, me) = TestBed::new();
10697 bed.swap_to(&owner);
10698 let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
10699 let rid = "b2".repeat(32);
10700 publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
10701 publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
10702
10703 let bundle = bundle_of(&host, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10704 let bundle_json = serde_json::to_string(&bundle).unwrap();
10705 bed.swap_to(&me);
10706 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10707 let _ = follow_control(&bed.relay, &joined).await;
10708 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10709
10710 let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
10713 mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
10714
10715 let before = bed.relay.stored_count();
10716 let new_root = [0xE1; 32];
10717 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
10718 let rotation_events = bed.relay.stored_count() - before;
10719
10720 let after_adopt = bed.relay.stored_count();
10721 follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
10722 assert_eq!(
10723 bed.relay.stored_count(),
10724 after_adopt,
10725 "adopting a rotation must publish NOTHING when I minted no links here",
10726 );
10727 assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
10728 }
10729
10730 #[tokio::test]
10731 async fn an_expired_link_stops_keeping_the_community_public() {
10732 let (_tmp, _guard, _owner) = init_test_db();
10738 let relay = MemoryRelay::new();
10739 let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
10740
10741 let past = now_ms() - 60_000;
10743 mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
10744 assert!(
10745 !community_is_public(&relay, &community).await,
10746 "an already-expired link must never read as a live door",
10747 );
10748
10749 mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
10751 assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
10752 }
10753
10754 #[tokio::test]
10755 async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
10756 let (_tmp, _guard, _owner) = init_test_db();
10759 let relay = MemoryRelay::new();
10760 let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
10761 assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
10762
10763 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
10764 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
10765 let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
10766 assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
10767
10768 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
10769 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
10770 assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
10771 let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
10772 assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
10773 }
10774
10775 #[tokio::test]
10776 async fn the_registry_is_cached_locally_so_public_private_is_a_sync_read() {
10777 let (_tmp, _guard, _owner) = init_test_db();
10781 let relay = MemoryRelay::new();
10782 let community = create_community(&relay, "Cached", vec!["wss://r".into()], None).await.unwrap();
10783 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10784 let cached = || crate::db::community::get_community_invite_registry(&cid_hex).unwrap();
10785 let per_creator = || crate::db::community::get_invite_link_sets(&cid_hex).unwrap();
10788 assert!(cached().is_empty(), "a fresh community caches an empty registry");
10789 assert!(per_creator().is_empty(), "…and no per-creator sets");
10790
10791 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
10792 assert!(!cached().is_empty(), "minting caches the registry, so the UI reads Public without folding");
10793 let sets = per_creator();
10794 assert_eq!(sets.len(), 1, "the minting creator gets a set");
10795 assert_eq!(sets[0].locators.len(), 1, "carrying exactly their one live link");
10796
10797 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
10799 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
10800 assert!(cached().is_empty(), "retiring the last link empties the cache back to Private");
10801 assert!(per_creator().is_empty(), "…and clears the per-creator sets");
10802 }
10803
10804 #[tokio::test]
10805 async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
10806 let (_tmp, _guard, owner) = init_test_db();
10813 let relay = MemoryRelay::new();
10814 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
10815 mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
10816 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
10817
10818 let cid = community.id();
10819 let control = control::ControlPlane::of(&community).write_group().unwrap();
10820 let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
10821
10822 let query = Query {
10823 kinds: vec![stream::KIND_WRAP],
10824 authors: vec![control.pk_hex()],
10825 limit: Some(FOLLOW_PAGE),
10826 ..Default::default()
10827 };
10828 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
10829 let target = wraps
10830 .iter()
10831 .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
10832 .filter(|e| e.entity_id == eid)
10833 .max_by_key(|e| e.version)
10834 .expect("the owner published a registry");
10835
10836 let rogue = Keys::generate();
10839 let mut planted = false;
10840 for n in 0..4_000u64 {
10841 let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
10842 let rumor = control::build_edition_rumor(
10843 rogue.public_key(),
10844 vsk::INVITE_LINKS,
10845 &eid,
10846 target.version,
10847 target.prev_hash.as_ref(),
10848 &content,
10849 9_000,
10850 None,
10851 );
10852 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
10853 let (ed, _) = control::open_control_edition(&w, &control).unwrap();
10854 if ed.inner_id < target.inner_id {
10855 relay.publish(&w, &community.relays).await.unwrap();
10856 planted = true;
10857 break;
10858 }
10859 }
10860 assert!(planted, "the test needs a fork that wins the tiebreak");
10861
10862 assert!(
10863 community_is_public(&relay, &community).await,
10864 "an unauthorised fork must not retire the owner's live link"
10865 );
10866 }
10867
10868 #[tokio::test]
10869 async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
10870 let (_tmp, _guard, owner) = init_test_db();
10872 let relay = MemoryRelay::new();
10873 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
10874 let rogue = Keys::generate();
10875 let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
10877 let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
10878 let group = control::ControlPlane::of(&community).write_group().unwrap();
10879 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
10880 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
10881 relay.publish(&wrap, &community.relays).await.unwrap();
10882 let _ = owner;
10883 assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
10884 }
10885
10886 #[tokio::test]
10887 async fn full_lifecycle_e2e() {
10888 let (bed, owner, member) = TestBed::new();
10894
10895 bed.swap_to(&owner);
10896 let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
10897 let general = community.channels[0].id;
10898 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
10899
10900 let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
10902 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
10903
10904 let rid = "aa".repeat(32);
10906 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
10907 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
10908
10909 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10911 let bundle_json = serde_json::to_string(&bundle).unwrap();
10912 bed.swap_to(&member);
10913 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10914 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
10915 let _ = follow_control(&bed.relay, &joined).await;
10918 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10919 assert!(joined.control_root.is_some(), "the Grant's control_wrap was adopted on fold");
10920 publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
10921
10922 bed.swap_to(&owner);
10924 let updated = follow_control(&bed.relay, &community).await.unwrap().expect("the admin edit folds");
10925 assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
10926
10927 set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
10929 grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
10930 let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
10931 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
10932 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
10934 assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
10935 assert!(
10937 all_texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
10938 "pre-refounding history stays reachable by paging"
10939 );
10940
10941 bed.swap_to(&member);
10944 let session = crate::db::current_session();
10945 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
10946 assert!(follow.self_removed, "the banned member is cryptographically cut");
10947
10948 bed.swap_to(&owner);
10950 dissolve_community(&bed.relay, &refounded).await.unwrap();
10951 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
10952 }
10953
10954 #[tokio::test]
10960 async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
10961 let (bed, owner, member) = TestBed::new();
10966 let attacker = Keys::generate();
10967 bed.swap_to(&owner);
10968 let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
10969 let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
10970 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
10971 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
10972 follow_control(&bed.relay, &community).await.unwrap();
10974 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
10975
10976 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;
10978
10979 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
10981 assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
10982
10983 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
10985 assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
10986 }
10987
10988 #[tokio::test]
10989 async fn memberlist_survives_a_refounding_via_the_snapshot() {
10990 let (bed, owner, member) = TestBed::new();
10993 bed.swap_to(&owner);
10994 let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
10995
10996 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
10998 bed.swap_to(&member);
10999 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
11000 bed.swap_to(&owner);
11001 assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
11002
11003 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
11005 assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
11006
11007 let members = memberlist(&bed.relay, &refounded).await.unwrap();
11009 assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
11010 assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
11011 }
11012
11013 #[tokio::test]
11014 async fn e2e_two_accounts_channels_converse_moderate() {
11015 use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
11016 use nostr_sdk::prelude::ToBech32;
11017 let (bed, a, b) = TestBed::new();
11018 let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
11019 let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
11020 println!("\n===== Concord v2 deep e2e =====");
11021 println!("[acct] A (owner) = {a_npub}");
11022 println!("[acct] B (member) = {b_npub}");
11023
11024 bed.swap_to(&a);
11026 let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
11027 let general = community.channels[0].id;
11028 println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
11029
11030 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
11034 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11035 let priv_ch = community.channel(&priv_id).unwrap();
11036 assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
11037 println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
11038
11039 let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
11041 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11042 let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
11043 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11044 println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
11045 assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
11046
11047 let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
11049 send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
11050 send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
11051 println!("[msg] A posted in #general / #announcements / #mods");
11052
11053 let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
11055 publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
11056 publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
11057 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
11058 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
11059 println!("[invite] granted B @admin · minted link {}", link.url);
11060
11061 grant_channel_access(&bed.relay, &community, &priv_id, &b.keys.public_key()).await.unwrap();
11064 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(b.keys.public_key()), Some(a.keys.public_key()), None, None)).unwrap();
11065 bed.swap_to(&b);
11066 let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
11067 println!("[join] B joined; sees {} channels", b_view.channels.len());
11068 assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
11069 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");
11070 assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
11071 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");
11072 if let Some(fresh) = follow_control(&bed.relay, &b_view).await.unwrap() {
11075 b_view = fresh;
11076 }
11077 println!("[follow] B folded control (roster persisted: B is @admin)");
11078
11079 send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
11081 send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
11082 println!("[msg] B replied in #general + #mods");
11083 let my_pk = b.keys.public_key();
11086 let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
11087 for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
11088 let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
11089 if let Some(o) = outcome { persist_chat(&gh, &o).await; }
11090 }
11091 assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
11092 println!("[persist] #general history persisted into the shared events store");
11093
11094 send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
11096 bed.swap_to(&a);
11097 let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
11098 send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
11099 let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
11100 send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
11101 println!("[ops] reaction + edit + delete round-tripped");
11102
11103 bed.swap_to(&b);
11105 let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
11106 println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
11107 bed.swap_to(&a);
11108 if let Some(updated) = follow_control(&bed.relay, &community).await.unwrap() {
11109 community = updated;
11110 }
11111 assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
11112 println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
11113
11114 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
11120 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11121 send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
11122 println!("[channel] +private #vault {} (B is unentitled — no delivery)", crate::simd::hex::bytes_to_hex_32(&vault.0));
11123 bed.swap_to(&b);
11124 let session_b2 = crate::db::current_session();
11125 if let Some(fresh) = follow_control(&bed.relay, &b_view).await.unwrap() {
11126 b_view = fresh;
11127 }
11128 let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
11129 assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
11130 let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
11131 if let Some(fresh) = rf.updated {
11132 b_view = fresh;
11133 }
11134 let ch = b_view.channel(&vault).expect("still recorded");
11135 assert!(ch.key.is_none(), "an unentitled member is never delivered the key");
11136 assert!(
11137 texts_in(&bed.relay, &b_view, &vault).await.is_empty(),
11138 "and reads nothing from it"
11139 );
11140 assert!(
11141 send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.is_err(),
11142 "an unentitled member cannot post into the channel either"
11143 );
11144 bed.swap_to(&a);
11145 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11146 println!("[private] #vault stayed sealed to the unentitled B (no key, no read, no send)");
11147
11148 let members = memberlist(&bed.relay, &community).await.unwrap();
11150 let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
11151 assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
11152 println!("[members] {} members: A + B present", members.len());
11153
11154 set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
11156 grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
11157 let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
11158 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
11159 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
11160 assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
11161 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");
11162 assert!(
11163 all_texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
11164 "PRIVATE history pages back across the channel's own rotation (per-channel multi-epoch archive)"
11165 );
11166 println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
11167 bed.swap_to(&b);
11169 let session_b3 = crate::db::current_session();
11170 assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
11171 println!("[ban] B's rekey-follow: self_removed = true (severed)");
11172
11173 bed.swap_to(&a);
11175 set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
11176 let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
11177 assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
11178 println!("[unban] B removed from the banlist (re-invitable)");
11179
11180 dissolve_community(&bed.relay, &refounded).await.unwrap();
11182 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
11183 println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
11184 }
11185
11186 #[tokio::test]
11192 #[ignore]
11193 async fn live_e2e_two_accounts() {
11194 use crate::community::transport::LiveTransport;
11195 use nostr_sdk::prelude::ToBech32;
11196
11197 let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
11198 let relays = vec![relay.clone()];
11199 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
11200 crate::db::close_database();
11201 crate::db::clear_id_caches();
11202 let tmp = tempfile::tempdir().unwrap();
11203 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
11204
11205 let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
11207 let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
11208
11209 let log = |line: String| {
11210 println!("{line}");
11211 if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
11212 use std::io::Write;
11213 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
11214 let _ = writeln!(f, "{line}");
11215 }
11216 }
11217 };
11218 log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
11219 log(format!("VECTOR_E2E_NSEC_A={} ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
11220 log(format!("VECTOR_E2E_NSEC_B={} ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
11221
11222 for k in [&a, &b] {
11223 let npub = k.public_key().to_bech32().unwrap();
11224 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
11225 crate::db::set_current_account(npub.clone()).unwrap();
11226 crate::db::init_database(&npub).unwrap();
11227 }
11228 let client = crate::nostr_client_builder().build();
11231 client.add_managed_relay(relay.as_str()).await.ok();
11232 client.connect().await;
11233 crate::state::set_nostr_client(client);
11234 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
11235 let become_acct = |k: &Keys| {
11236 let npub = k.public_key().to_bech32().unwrap();
11237 crate::db::set_current_account(npub.clone()).unwrap();
11238 crate::db::init_database(&npub).unwrap();
11239 crate::db::clear_id_caches();
11240 crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
11241 crate::state::set_my_public_key(k.public_key());
11242 };
11243 let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
11244
11245 become_acct(&a);
11247 let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
11248 let general = community.channels[0].id;
11249 log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
11250 send_message(&transport, &community, &general, "A: live hello").await.expect("send");
11251 let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
11252 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11253 log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
11254 grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
11255 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
11256 log(format!("[invite] B granted @admin · link {}", link.url));
11257 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(a.public_key()), None, None)).unwrap();
11258 settle().await;
11259
11260 become_acct(&b);
11262 let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
11263 log(format!("[join] B joined; {} channels", b_view.channels.len()));
11264 settle().await;
11265 let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
11266 let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
11267 log(format!("[read] B sees #general: {seen:?}"));
11268 assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
11269 send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
11270
11271 let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
11275 let hello_id = hello.event.opened().rumor_id.to_hex();
11276 let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
11277 let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
11278 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());
11279 let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
11280 transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
11281 log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
11282 settle().await;
11283
11284 become_acct(&a);
11286 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11287 let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
11288 let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
11289 if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
11290 assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
11291 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");
11292 } else {
11293 panic!("the 1111 parsed as a Message");
11294 }
11295 log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
11296 become_acct(&b);
11297 settle().await;
11298
11299 become_acct(&a);
11303 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11304 let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
11305 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11306 send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
11307 log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
11308 settle().await;
11309
11310 become_acct(&b);
11311 let session_b = crate::db::current_session();
11312 let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
11313 if let Some(fresh) = follow_control(&transport, &b_view).await.expect("B control follow") {
11314 b_view = fresh;
11315 }
11316 if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
11317 b_view = fresh;
11318 }
11319 let vch = b_view.channel(&vault).expect("B folded the vault");
11320 assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
11321 let vseen = texts_in(&transport, &b_view, &vault).await;
11322 log(format!("[read] B sees #vault: {vseen:?}"));
11323 assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
11324 send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
11325 settle().await;
11326
11327 become_acct(&a);
11328 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11329 assert!(
11330 texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
11331 "A reads B's private reply"
11332 );
11333 log("[private] two-way #vault conversation over the live relay".to_string());
11334
11335 set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
11337 grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
11338 let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
11339 log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
11340 settle().await;
11341 dissolve_community(&transport, &refounded).await.expect("dissolve");
11342 log("[dissolve] community sealed".to_string());
11343 log("===== LIVE e2e PASS =====".to_string());
11344 }
11345
11346 #[tokio::test]
11347 async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
11348 let (bed, owner, member) = TestBed::new();
11352 bed.swap_to(&owner);
11353 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
11354 let general = community.channels[0].id;
11355 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
11356
11357 bed.swap_to(&member);
11358 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
11359 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
11360
11361 bed.swap_to(&owner);
11363 dissolve_community(&bed.relay, &community).await.unwrap();
11364
11365 bed.swap_to(&member);
11367 let session = crate::db::current_session();
11368 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
11369 assert!(follow.dissolved, "the catch-up surfaces the tombstone");
11370 assert!(!follow.self_removed && follow.updated.is_none());
11371 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
11372 assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
11373 let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
11374 assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
11375 let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
11377 assert!(again.dissolved && again.updated.is_none());
11378 }
11379
11380 #[tokio::test]
11381 async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
11382 const PRIV_CHANNELS: usize = 6;
11387 let (bed, owner, member) = TestBed::new();
11388 bed.swap_to(&owner);
11389 let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
11390 let mut priv_ids = Vec::new();
11391 for i in 0..PRIV_CHANNELS {
11392 let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
11393 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11394 send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
11395 priv_ids.push(id);
11396 }
11397 for id in &priv_ids {
11399 grant_channel_access(&bed.relay, &community, id, &member.keys.public_key()).await.unwrap();
11400 }
11401 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
11402
11403 bed.swap_to(&member);
11405 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
11406 assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
11407
11408 bed.swap_to(&owner);
11410 for epoch in 1..=2u64 {
11411 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
11412 assert_eq!(community.root_epoch, Epoch(epoch));
11413 for id in &priv_ids {
11414 send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
11415 }
11416 }
11417
11418 bed.swap_to(&member);
11420 let session = crate::db::current_session();
11421 let mut passes = 0;
11422 loop {
11423 passes += 1;
11424 assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
11425 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
11426 let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
11427 assert!(!rk.self_removed);
11428 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
11429 let ctl = follow_control(&bed.relay, &cur).await.unwrap();
11430 if rk.updated.is_none() && ctl.is_none() {
11431 break;
11432 }
11433 }
11434 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
11435 assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
11436 for id in &priv_ids {
11438 let mine = caught_up.channel(id).expect("channel survived");
11439 let theirs = community.channel(id).unwrap();
11440 assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
11441 assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
11442 let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
11443 let open = texts_in(&bed.relay, &caught_up, id).await;
11447 assert!(open.iter().any(|t| t.contains("epoch2")), "channel {id_hex} opens onto its live epoch");
11448 let paged = all_texts_in(&bed.relay, &caught_up, id).await;
11449 assert!(paged.iter().any(|t| t.contains("epoch0")), "channel {id_hex} pages back to epoch-0 history");
11450 for epoch in 1..=2u64 {
11451 assert!(paged.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} pages back to epoch-{epoch} history");
11452 }
11453 }
11454 }
11455
11456 #[tokio::test]
11457 async fn an_offline_member_catches_up_across_three_refoundings() {
11458 use nostr_sdk::prelude::ToBech32;
11463 let (bed, owner, member) = TestBed::new();
11464 bed.swap_to(&owner);
11465 let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
11466 let general = community.channels[0].id;
11467 let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
11468 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11469 send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
11470 send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
11471 grant_channel_access(&bed.relay, &community, &mods, &member.keys.public_key()).await.unwrap();
11473 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
11474
11475 bed.swap_to(&member);
11477 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
11478 assert_eq!(member_view.root_epoch, Epoch(0));
11479
11480 bed.swap_to(&owner);
11482 let stranger = Keys::generate();
11483 for epoch in 1..=3u64 {
11484 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
11485 assert_eq!(community.root_epoch, Epoch(epoch));
11486 send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
11487 send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
11488 }
11489 let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
11490 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11491 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
11492 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11493 grant_channel_access(&bed.relay, &community, &vault, &member.keys.public_key()).await.unwrap();
11496 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11497 send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
11498 set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
11499 let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
11500 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
11501
11502 bed.swap_to(&member);
11505 let session = crate::db::current_session();
11506 let mut passes = 0;
11507 loop {
11508 passes += 1;
11509 assert!(passes <= 6, "catch-up must converge, not churn");
11510 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
11511 let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
11512 assert!(!rekeyed.self_removed, "the member was never removed");
11513 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
11514 let controlled = follow_control(&bed.relay, &cur).await.unwrap();
11515 if rekeyed.updated.is_none() && controlled.is_none() {
11516 break;
11517 }
11518 }
11519 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
11520
11521 assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
11523 assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
11524 assert_eq!(caught_up.name, "Sleeper Reborn");
11525 assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
11527 let m = caught_up.channel(&mods).expect("mods survived");
11528 let owner_mods = community.channel(&mods).unwrap();
11529 assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
11530 assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
11531 let v = caught_up.channel(&vault).expect("vault folded in");
11532 assert!(v.private && v.key.is_none(), "vault folds in keyless: entitled, but never delivered");
11537 let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
11539 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
11540 assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
11541 let gen_open = texts_in(&bed.relay, &caught_up, &general).await;
11546 assert!(gen_open.contains(&"epoch3: general news".to_string()), "general opens onto its live epoch: {gen_open:?}");
11547 let gen_texts = all_texts_in(&bed.relay, &caught_up, &general).await;
11548 for epoch in 0..=3u64 {
11549 let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
11550 assert!(gen_texts.contains(&needle), "general pages back to epoch {epoch}: {gen_texts:?}");
11551 }
11552 let mods_open = texts_in(&bed.relay, &caught_up, &mods).await;
11553 assert!(mods_open.contains(&"epoch3: mods word".to_string()), "mods opens onto its live epoch: {mods_open:?}");
11554 let mods_texts = all_texts_in(&bed.relay, &caught_up, &mods).await;
11555 for epoch in 0..=3u64 {
11556 let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
11557 assert!(mods_texts.contains(&needle), "private pages back to epoch {epoch}: {mods_texts:?}");
11558 }
11559 assert!(texts_in(&bed.relay, &caught_up, &vault).await.is_empty());
11562 send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
11564 bed.swap_to(&owner);
11565 assert!(
11566 texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
11567 "the caught-up member converses at the new epoch ({})",
11568 member.keys.public_key().to_bech32().unwrap()
11569 );
11570 }
11571
11572 async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
11575 let general = community.channels[0].id;
11576 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
11577 for i in 0..n {
11578 let at = base_secs + i as u64;
11579 let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
11580 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
11581 relay.publish(&wrap, &community.relays).await.unwrap();
11582 }
11583 }
11584
11585 #[tokio::test]
11586 async fn the_history_walk_pages_past_a_multi_page_burst() {
11587 let (_tmp, _guard, owner) = init_test_db();
11590 let relay = MemoryRelay::new();
11591 let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
11592 let general = community.channels[0].id;
11593 flood_general(&relay, &community, &owner, 120, 10_000).await;
11594
11595 let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
11596 assert_eq!(all.len(), 120, "the walk pages the whole burst");
11597 let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
11599 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
11600 assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
11601 let unique: std::collections::HashSet<&String> = contents.iter().collect();
11602 assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
11603
11604 let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
11606 assert_eq!(one.len(), 50, "fetch_channel is one newest page");
11607 assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
11608 }
11609
11610 #[tokio::test]
11611 async fn a_start_until_cursor_pages_history_from_that_point_backwards() {
11612 let (_tmp, _guard, owner) = init_test_db();
11616 let relay = MemoryRelay::new();
11617 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
11618 let general = community.channels[0].id;
11619 flood_general(&relay, &community, &owner, 120, 10_000).await;
11620
11621 let older = fetch_channel_history(
11622 &relay, &community, &general, 50, 8, None, Some(10_059),
11623 crate::community::transport::Evidence::Quorum, |_| true,
11624 )
11625 .await
11626 .unwrap();
11627 let contents: Vec<String> = older.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
11628 assert_eq!(contents.len(), 60, "everything at-or-before the cursor, nothing after");
11629 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
11630 assert_eq!(contents.last().map(String::as_str), Some("msg 59"));
11631 }
11632
11633 #[tokio::test]
11634 async fn the_history_walk_stops_when_the_caller_is_caught_up() {
11635 let (_tmp, _guard, owner) = init_test_db();
11636 let relay = MemoryRelay::new();
11637 let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
11638 let general = community.channels[0].id;
11639 flood_general(&relay, &community, &owner, 120, 10_000).await;
11640
11641 let mut pages = 0usize;
11643 let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| {
11644 pages += 1;
11645 false
11646 })
11647 .await
11648 .unwrap();
11649 assert_eq!(pages, 1, "the early stop is consulted once");
11650 assert_eq!(got.len(), 50, "only the newest page is fetched");
11651 assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
11652 }
11653
11654 #[tokio::test]
11655 async fn a_same_second_history_wall_terminates_instead_of_looping() {
11656 let (_tmp, _guard, owner) = init_test_db();
11660 let relay = MemoryRelay::new();
11661 let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
11662 let general = community.channels[0].id;
11663 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
11664 for i in 0..60usize {
11665 let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
11666 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
11667 relay.publish(&wrap, &community.relays).await.unwrap();
11668 }
11669 let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
11670 assert!(got.len() >= 25, "at least the relay page is read");
11671 assert!(got.len() <= 60, "sane bound");
11672 }
11674
11675 #[tokio::test]
11676 async fn a_grant_revoke_survives_a_withholding_relay() {
11677 let (_tmp, _guard, owner) = init_test_db();
11680 let relay = MemoryRelay::new();
11681 let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
11682 let admin = Keys::generate();
11683 let rid = "d4".repeat(32);
11684 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
11685 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
11686 follow_control(&relay, &community).await.unwrap(); publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; follow_control(&relay, &community).await.unwrap();
11689
11690 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
11692 let mut stale = community.clone();
11693 stale.relays = vec!["wss://stale".into()];
11694 let floors = load_floors(&community);
11695 let editions = fetch_control(&relay, &stale).await;
11696 let authority = fold_authority(&stale, &editions, &floors);
11697 assert!(
11698 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
11699 "the persisted grant floor refuses the rolled-back (re-granted) view"
11700 );
11701 }
11702
11703 fn load_floors(community: &CommunityV2) -> Floors {
11705 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11706 crate::db::community::get_all_edition_heads_full(&cid_hex)
11707 .unwrap_or_default()
11708 .into_iter()
11709 .filter(|(_, f)| f.0 == community.root_epoch.0)
11710 .map(|(e, f)| (e, (f.1, f.2, f.3)))
11711 .collect()
11712 }
11713
11714 async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
11716 let group = control::ControlPlane::of(&community).write_group().unwrap();
11717 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
11718 relay
11719 .fetch(&q, &community.relays)
11720 .await
11721 .unwrap_or_default()
11722 .iter()
11723 .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
11724 .collect()
11725 }
11726
11727 #[tokio::test]
11728 async fn follow_control_is_a_noop_on_a_freshly_created_community() {
11729 let (_tmp, _guard, _owner) = init_test_db();
11730 let relay = MemoryRelay::new();
11731 let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
11732 assert!(follow_control(&relay, &community).await.unwrap().is_none());
11734 }
11735
11736 #[tokio::test]
11737 async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
11738 let (_tmp, _guard, owner) = init_test_db();
11739 let relay = MemoryRelay::new();
11740 let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
11741 let new_id = ChannelId([0x5a; 32]);
11742 publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
11743
11744 let updated = follow_control(&relay, &community).await.unwrap().expect("a new channel changed the view");
11745 assert_eq!(updated.channels.len(), 2);
11746 let added = updated.channel(&new_id).expect("the new channel folded in");
11747 assert_eq!(added.name, "announcements");
11748 assert!(!added.private);
11749 assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
11750
11751 let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
11753 let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
11754 assert!(authors.contains(&addr), "the added channel joins the live subscription");
11755
11756 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11758 assert!(reloaded.channel(&new_id).is_some());
11759 }
11760
11761 #[tokio::test]
11762 async fn follow_control_renames_the_community_and_an_existing_channel() {
11763 let (_tmp, _guard, owner) = init_test_db();
11764 let relay = MemoryRelay::new();
11765 let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
11766 let general = community.channels[0].id;
11767 publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
11769 publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
11770
11771 let updated = follow_control(&relay, &community).await.unwrap().unwrap();
11772 assert_eq!(updated.name, "New Name");
11773 assert_eq!(updated.channel(&general).unwrap().name, "lobby");
11774 assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
11775 }
11776
11777 #[tokio::test]
11778 async fn follow_control_deletes_a_channel() {
11779 let (_tmp, _guard, owner) = init_test_db();
11780 let relay = MemoryRelay::new();
11781 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
11782 let extra = ChannelId([0x77; 32]);
11783
11784 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
11786 let with_extra = follow_control(&relay, &community).await.unwrap().expect("added");
11787 assert!(with_extra.channel(&extra).is_some());
11788
11789 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
11791 let updated = follow_control(&relay, &with_extra).await.unwrap().expect("removed");
11792 assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
11793 assert_eq!(updated.channels.len(), 1, "only #general remains");
11794 }
11795
11796 async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
11800 let group = control::ControlPlane::of(&community).write_group().unwrap();
11801 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
11802 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
11803 for w in &wraps {
11804 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
11805 if ed.version <= max_version {
11806 relay.inject(w, &[stale_relay.to_string()]);
11807 }
11808 }
11809 }
11810 }
11811
11812 #[tokio::test]
11813 async fn a_withholding_relay_cannot_roll_back_a_rename() {
11814 let (_tmp, _guard, owner) = init_test_db();
11817 let relay = MemoryRelay::new();
11818 let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
11819 publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
11820
11821 let updated = follow_control(&relay, &community).await.unwrap().expect("rename adopted");
11822 assert_eq!(updated.name, "Renamed");
11823
11824 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
11826 let mut stale_view = updated.clone();
11827 stale_view.relays = vec!["wss://stale".into()];
11828 assert!(
11829 follow_control(&relay, &stale_view).await.unwrap().is_none(),
11830 "a stale-only relay must not change the held view"
11831 );
11832 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11833 assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
11834 }
11835
11836 #[tokio::test]
11837 async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
11838 let (_tmp, _guard, owner) = init_test_db();
11839 let relay = MemoryRelay::new();
11840 let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
11841 let extra = ChannelId([0x44; 32]);
11842
11843 publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
11846 assert!(follow_control(&relay, &community).await.unwrap().is_none());
11847
11848 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
11849 let with_extra = follow_control(&relay, &community).await.unwrap().expect("added");
11850 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
11851 let pruned = follow_control(&relay, &with_extra).await.unwrap().expect("removed");
11852 assert!(pruned.channel(&extra).is_none());
11853
11854 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
11856 let mut stale_view = pruned.clone();
11857 stale_view.relays = vec!["wss://stale".into()];
11858 assert!(
11859 follow_control(&relay, &stale_view).await.unwrap().is_none(),
11860 "the withheld delete must not resurrect the channel"
11861 );
11862 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11863 assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
11864 }
11865
11866 #[tokio::test]
11867 async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
11868 let (_tmp, _guard, owner) = init_test_db();
11872 let relay = MemoryRelay::new();
11873 let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
11874 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
11875 let updated = follow_control(&relay, &community).await.unwrap().expect("edit adopted");
11876 assert_eq!(updated.name, "Edited");
11877
11878 let refounded = rotate_view(&updated, updated.community_root, 1);
11881 crate::db::community::save_community_v2(&refounded).unwrap();
11882 publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
11883
11884 let adopted = follow_control(&relay, &refounded).await.unwrap().expect("compacted head adopted");
11885 assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
11886 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11888 let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
11889 assert!(
11890 heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
11891 "the adopted head carries the fold's epoch + version"
11892 );
11893 }
11894
11895 #[tokio::test]
11896 async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
11897 let (_tmp, _guard, owner) = init_test_db();
11902 let relay = MemoryRelay::new();
11903 let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
11904 let group = control::ControlPlane::of(&community).write_group().unwrap();
11905 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
11906
11907 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
11908 let ours = follow_control(&relay, &community).await.unwrap().expect("ours adopted");
11909 assert_eq!(ours.name, "Ours");
11910
11911 let our_inner = {
11913 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
11914 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
11915 wraps
11916 .iter()
11917 .find_map(|w| {
11918 control::open_control_edition(w, &group)
11919 .ok()
11920 .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
11921 .map(|(ed, _)| ed.inner_id)
11922 })
11923 .unwrap()
11924 };
11925
11926 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
11929 let content = serde_json::to_string(&meta).unwrap();
11930 let mut ts = 2_000u64;
11931 let fork_wrap = loop {
11932 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
11933 let inner = rumor.id.unwrap().to_bytes();
11934 if inner < our_inner {
11935 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
11936 }
11937 ts += 1;
11938 };
11939 relay.publish(&fork_wrap, &community.relays).await.unwrap();
11940
11941 let converged = follow_control(&relay, &ours).await.unwrap().expect("fork winner adopted");
11942 assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
11943 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11944 let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
11945 assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
11946 }
11947
11948 #[tokio::test]
11949 async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
11950 let (_tmp, _guard, owner) = init_test_db();
11954 let relay = MemoryRelay::new();
11955 let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
11956 let group = control::ControlPlane::of(&community).write_group().unwrap();
11957
11958 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
11959 let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
11960
11961 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
11963 let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
11964 let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
11965 let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
11966 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
11967 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
11968 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
11969 relay.publish(&w4, &community.relays).await.unwrap();
11970
11971 let updated = follow_control(&relay, &community).await.unwrap().expect("the verified prefix applies");
11972 assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
11973
11974 relay.publish(&w3, &community.relays).await.unwrap();
11975 let healed = follow_control(&relay, &updated).await.unwrap().expect("the chain heals");
11976 assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
11977 }
11978
11979 #[tokio::test]
11980 async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
11981 let (_tmp, _guard, owner) = init_test_db();
11985 let relay = MemoryRelay::new();
11986 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
11987 let group = control::ControlPlane::of(&community).write_group().unwrap();
11988
11989 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
11990 let base = follow_control(&relay, &community).await.unwrap().expect("floor at v2");
11991 publish_community_meta(&relay, &base, &owner, "Three", 3).await; let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
11993
11994 let rogue = Keys::generate();
11997 for i in 0..(FOLLOW_PAGE as u64 - 1) {
11998 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
11999 let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
12000 relay.publish(&w, &community.relays).await.unwrap();
12001 }
12002 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
12005 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
12006 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
12007 relay.publish(&w4, &community.relays).await.unwrap();
12008
12009 let healed = follow_control(&relay, &base).await.unwrap().expect("paging recovered the chain");
12010 assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
12011 }
12012
12013 #[tokio::test]
12014 async fn a_follow_after_delete_does_not_resurrect_the_community() {
12015 let (_tmp, _guard, owner) = init_test_db();
12018 let relay = MemoryRelay::new();
12019 let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
12020 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
12021 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12022 crate::db::community::delete_community(&cid_hex).unwrap();
12023
12024 assert!(
12025 follow_control(&relay, &community).await.unwrap().is_none(),
12026 "a follow racing a delete is a no-op"
12027 );
12028 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
12029 assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
12030 }
12031
12032 #[tokio::test]
12033 async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
12034 let (_tmp, _guard, owner) = init_test_db();
12037 let relay = MemoryRelay::new();
12038 let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
12039 let new_root = [0xB2; 32];
12040 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
12041 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12042 crate::db::community::delete_community(&cid_hex).unwrap();
12043
12044 let session = crate::db::current_session();
12045 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
12046 assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
12047 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
12048 }
12049
12050 #[tokio::test]
12051 async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
12052 let (bed, owner, member) = TestBed::new();
12057 bed.swap_to(&owner);
12058 let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
12059 let group = control::ControlPlane::of(&community).write_group().unwrap();
12060 let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
12061
12062 let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
12064 let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
12065 let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
12066 let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
12067 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
12068 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);
12069 let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
12070 bed.relay.publish(&w3, &community.relays).await.unwrap();
12071
12072 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
12073 let bundle_json = serde_json::to_string(&bundle).unwrap();
12074 bed.swap_to(&member);
12075 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
12076 assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
12077 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
12078 let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
12079 assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
12080 }
12081
12082 #[tokio::test]
12083 async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
12084 let (_tmp, _guard, owner) = init_test_db();
12088 let relay = MemoryRelay::new();
12089 let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
12090 let group = control::ControlPlane::of(&community).write_group().unwrap();
12091 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
12092
12093 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
12094 let ours = follow_control(&relay, &community).await.unwrap().expect("ours adopted");
12095 assert_eq!(ours.name, "Ours");
12096 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12097 let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
12098 let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
12099
12100 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
12103 let content = serde_json::to_string(&meta).unwrap();
12104 let mut ts = 5_000u64;
12105 let fork_wrap = loop {
12106 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
12107 if rumor.id.unwrap().to_bytes() > our_inner {
12108 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
12109 }
12110 ts += 1;
12111 };
12112 inject_stale_prefix(&relay, &community, 1, "wss://stale").await; relay.inject(&fork_wrap, &["wss://stale".to_string()]);
12114 let mut stale_view = ours.clone();
12115 stale_view.relays = vec!["wss://stale".into()];
12116
12117 assert!(
12118 follow_control(&relay, &stale_view).await.unwrap().is_none(),
12119 "a losing fork served without our floor edition changes nothing"
12120 );
12121 let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
12122 assert_eq!(held_after, held_before, "the floor row is untouched");
12123 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12124 assert_eq!(held.name, "Ours", "the held state is untouched");
12125 }
12126
12127 #[tokio::test]
12128 async fn follow_control_ignores_a_non_owner_edition() {
12129 let (_tmp, _guard, _owner) = init_test_db();
12133 let relay = MemoryRelay::new();
12134 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
12135 let rogue = Keys::generate();
12136 let rogue_id = ChannelId([0x99; 32]);
12137 publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
12138
12139 assert!(
12140 follow_control(&relay, &community).await.unwrap().is_none(),
12141 "a non-owner control edition is not folded"
12142 );
12143 }
12144
12145 #[tokio::test]
12146 async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
12147 let (_tmp, _guard, owner) = init_test_db();
12151 let relay = MemoryRelay::new();
12152 let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
12153 let priv_id = ChannelId([0x33; 32]);
12154 publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
12155
12156 let updated = follow_control(&relay, &community)
12157 .await
12158 .unwrap()
12159 .expect("the keyless record is a change");
12160 let ch = updated.channel(&priv_id).expect("the private channel is recorded");
12161 assert!(ch.private && ch.key.is_none(), "recorded keyless");
12162 assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
12163 assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
12164 assert!(
12165 fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
12166 "a keyless fetch returns empty (and never queries the root plane)"
12167 );
12168 assert!(
12169 send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
12170 "a keyless send refuses"
12171 );
12172 let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
12175 let rch = reloaded.channel(&priv_id).unwrap();
12176 assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
12177 let bundle = bundle_of(&reloaded, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
12181 assert!(
12182 !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
12183 "an ungrantable keyless channel stays out of invite bundles"
12184 );
12185 }
12186
12187 #[tokio::test]
12188 async fn a_link_bundle_never_carries_a_private_channel_key() {
12189 let (_tmp, _guard, _owner) = init_test_db();
12194 let relay = MemoryRelay::new();
12195 let community = create_community(&relay, "Leak", vec!["wss://r".into()], None).await.unwrap();
12196 create_private_channel(&relay, &community, "mods").await.unwrap();
12197 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12198 let priv_hex = held
12199 .channels
12200 .iter()
12201 .find(|c| c.private)
12202 .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
12203 .expect("the private channel is held WITH its key");
12204
12205 let link = bundle_of(&held, BundleAudience::Link, None, None, None);
12206 assert!(
12207 !link.channels.iter().any(|c| c.id == priv_hex),
12208 "a held private key must never ride a link bundle"
12209 );
12210 assert!(
12211 link.channels.iter().any(|c| c.id != priv_hex),
12212 "the public channels still ride it"
12213 );
12214
12215 let stranger = bundle_of(&held, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
12219 assert!(
12220 !stranger.channels.iter().any(|c| c.id == priv_hex),
12221 "an unentitled member gets no private key"
12222 );
12223 let mine = bundle_of(&held, BundleAudience::Member(me_pk().unwrap()), None, None, None);
12224 assert!(
12225 mine.channels.iter().any(|c| c.id == priv_hex),
12226 "the creator is entitled via the companion access role"
12227 );
12228 }
12229
12230 async fn seat_ranked_role(
12234 relay: &MemoryRelay,
12235 community: &CommunityV2,
12236 member: &PublicKey,
12237 name: &str,
12238 position: u32,
12239 permissions: crate::community::roles::Permissions,
12240 ) -> String {
12241 let role_id = crate::crypto::sha256_hex(format!("test/role/{name}/{position}").as_bytes());
12242 let role = crate::community::roles::Role {
12243 role_id: role_id.clone(),
12244 name: name.to_string(),
12245 position,
12246 permissions,
12247 scope: crate::community::roles::RoleScope::Server,
12248 color: 0,
12249 };
12250 set_role(relay, community, &role).await.unwrap();
12251 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12252 let mut ids: Vec<String> = {
12253 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12254 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12255 roster.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect()
12256 };
12257 if !ids.contains(&role_id) {
12258 ids.push(role_id.clone());
12259 }
12260 grant_roles(relay, &held, member, ids).await.unwrap();
12261 role_id
12262 }
12263
12264 fn act_as(who: &Keys) {
12267 crate::state::MY_SECRET_KEY.store_from_keys(who, &[]);
12268 crate::state::set_my_public_key(who.public_key());
12269 }
12270
12271 #[tokio::test]
12272 async fn a_moderator_cannot_revoke_channel_access_from_a_superior_and_the_channel_never_rotates() {
12273 let (_tmp, _guard, owner) = init_test_db();
12279 let relay = MemoryRelay::new();
12280 let community = create_community(&relay, "Ranked", vec!["wss://r".into()], None).await.unwrap();
12281 let vault = create_private_channel(&relay, &community, "vault").await.unwrap();
12282
12283 let superior = Keys::generate();
12284 let moderator = Keys::generate();
12285 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12286 grant_admin(&relay, &held, &superior.public_key()).await.unwrap();
12287 seat_ranked_role(
12288 &relay,
12289 &community,
12290 &moderator.public_key(),
12291 "Moderator",
12292 5,
12293 crate::community::roles::Permissions(crate::community::roles::Permissions::MANAGE_ROLES),
12294 )
12295 .await;
12296
12297 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12299 grant_channel_access(&relay, &held, &vault, &superior.public_key()).await.unwrap();
12300 let before = crate::db::community::load_community_v2(community.id())
12301 .unwrap()
12302 .unwrap()
12303 .channel(&vault)
12304 .unwrap()
12305 .epoch;
12306
12307 act_as(&moderator);
12308 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12309 let err = revoke_channel_access(&relay, &held, &vault, &superior.public_key())
12310 .await
12311 .expect_err("a moderator must not act on an admin");
12312 assert!(err.contains("outrank"), "refused for RANK, not some incidental reason: {err}");
12313
12314 let after = crate::db::community::load_community_v2(community.id())
12315 .unwrap()
12316 .unwrap()
12317 .channel(&vault)
12318 .unwrap()
12319 .epoch;
12320 assert_eq!(after, before, "the refusal must also stop the rotation — the rekey IS the severance");
12321 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12322 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12323 let chan_hex = crate::simd::hex::bytes_to_hex_32(&vault.0);
12324 let owner_hex = owner.public_key().to_hex();
12325 assert!(
12326 roster.is_entitled(Some(&owner_hex), &superior.public_key().to_hex(), &chan_hex, &[], &[]),
12327 "and the superior keeps their access role"
12328 );
12329 }
12330
12331 #[tokio::test]
12332 async fn a_moderator_cannot_grant_channel_access_to_a_superior_but_the_owner_stays_grantable() {
12333 let (_tmp, _guard, owner) = init_test_db();
12337 let relay = MemoryRelay::new();
12338 let community = create_community(&relay, "Ranked", vec!["wss://r".into()], None).await.unwrap();
12339 let vault = create_private_channel(&relay, &community, "vault").await.unwrap();
12340
12341 let superior = Keys::generate();
12342 let moderator = Keys::generate();
12343 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12344 grant_admin(&relay, &held, &superior.public_key()).await.unwrap();
12345 let chan_hex = crate::simd::hex::bytes_to_hex_32(&vault.0);
12346 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12349 grant_channel_access(&relay, &held, &vault, &moderator.public_key()).await.unwrap();
12350 seat_ranked_role(
12351 &relay,
12352 &community,
12353 &moderator.public_key(),
12354 "Moderator",
12355 5,
12356 crate::community::roles::Permissions(crate::community::roles::Permissions::MANAGE_ROLES),
12357 )
12358 .await;
12359
12360 act_as(&moderator);
12361 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12362 let err = grant_channel_access(&relay, &held, &vault, &superior.public_key())
12363 .await
12364 .expect_err("a moderator must not grant against an admin");
12365 assert!(err.contains("outrank"), "refused for RANK: {err}");
12366
12367 grant_channel_access(&relay, &held, &vault, &owner.public_key())
12369 .await
12370 .expect("the owner is not a rank target, and may still be handed a key");
12371 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12372 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12373 let owner_hex = owner.public_key().to_hex();
12374 assert!(
12375 roster.is_entitled(Some(&owner_hex), &owner_hex, &chan_hex, &[], &[]),
12376 "the owner's access role landed"
12377 );
12378 }
12379
12380 #[tokio::test]
12381 async fn a_private_channel_mints_its_access_role_and_entitlement_follows_the_grant() {
12382 let (_tmp, _guard, _owner) = init_test_db();
12385 let relay = MemoryRelay::new();
12386 let community = create_community(&relay, "Scoped", vec!["wss://r".into()], None).await.unwrap();
12387 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
12388 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12389 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12390
12391 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12392 let access = roster.channel_roles(&chan_hex);
12393 assert_eq!(access.len(), 1, "the channel minted exactly one access role");
12394 assert!(
12395 access[0].permissions == crate::community::roles::Permissions::empty(),
12396 "the access role confers READ access (key possession), never authority"
12397 );
12398 assert_eq!(access[0].name, "mods", "named for its channel");
12399
12400 let stranger = Keys::generate().public_key();
12402 let owner_hex = community.owner().unwrap().to_hex();
12403 assert!(!roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]));
12404
12405 let role_id = access[0].role_id.clone();
12408 assert!(
12409 roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, std::slice::from_ref(&role_id), &[]),
12410 "the grant overlay entitles before the fold catches up"
12411 );
12412
12413 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12414 grant_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
12415 let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
12416 assert!(
12417 after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
12418 "the grant landed in the local roster (the fold runs later)"
12419 );
12420 let vend = bundle_of(&held, BundleAudience::Member(stranger), None, None, None);
12421 assert!(
12422 vend.channels.iter().any(|c| c.id == chan_hex),
12423 "a now-entitled member's bundle carries the channel key"
12424 );
12425
12426 revoke_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
12427 let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
12428 assert!(
12429 !after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
12430 "the revoke dropped the access role"
12431 );
12432 let rotated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12433 assert_eq!(
12434 rotated.channel(&priv_id).unwrap().epoch,
12435 Epoch(2),
12436 "the revoke rotated the channel — a removal that doesn't rekey severs nobody"
12437 );
12438
12439 let access = crate::VectorCore.channel_access(&cid_hex, &chan_hex).unwrap();
12441 assert_eq!(access["private"], true);
12442 assert_eq!(access["readable"], true, "we minted it, so we hold its key");
12443 assert_eq!(access["roles"].as_array().unwrap().len(), 1, "one access role");
12444 let holders = access["members"].as_array().unwrap();
12445 let me_npub = {
12446 use nostr_sdk::prelude::ToBech32;
12447 me_pk().unwrap().to_bech32().unwrap()
12448 };
12449 assert_eq!(holders.len(), 1, "only the creator holds it — the revoked member is gone");
12450 assert_eq!(holders[0], serde_json::json!(me_npub), "and that holder is the creator");
12451 }
12452
12453 #[tokio::test]
12454 async fn a_public_ban_severs_the_private_channels_the_member_could_read() {
12455 let (_tmp, _guard, _owner) = init_test_db();
12460 let relay = MemoryRelay::new();
12461 let community = create_community(&relay, "Sever", vec!["wss://r".into()], None).await.unwrap();
12462 let mods = create_private_channel(&relay, &community, "mods").await.unwrap();
12463 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12464 let vault = create_private_channel(&relay, &held, "vault").await.unwrap();
12465 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12466
12467 let spammer = Keys::generate().public_key();
12468 let bystander = Keys::generate().public_key();
12469 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12470 grant_channel_access(&relay, &held, &mods, &spammer).await.unwrap();
12471 grant_channel_access(&relay, &held, &mods, &bystander).await.unwrap();
12472
12473 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12476 let stripped: Vec<String> = roster.roles_of(&spammer.to_hex()).map(|r| r.role_id.clone()).collect();
12477 assert!(!stripped.is_empty(), "the grant landed before the strip");
12478 grant_roles(&relay, &held, &spammer, vec![]).await.unwrap();
12479
12480 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12481 let rotated = sever_banned_private_reads(&relay, &held, &[(spammer, stripped)]).await.unwrap();
12482 assert_eq!(rotated, 1, "exactly the one channel they could read rotated");
12483 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12484 assert_eq!(after.channel(&mods).unwrap().epoch, Epoch(2), "the reachable channel advanced");
12485 assert_eq!(after.channel(&vault).unwrap().epoch, Epoch(1), "the unreachable channel did not");
12486
12487 let stranger = Keys::generate().public_key();
12489 let n = sever_banned_private_reads(&relay, &after, &[(stranger, Vec::new())]).await.unwrap();
12490 assert_eq!(n, 0, "no entitlement, no rotation");
12491 }
12492
12493 #[tokio::test]
12494 async fn ban_severance_refuses_without_manage_channels() {
12495 let (bed, owner, moderator) = TestBed::new();
12499 bed.swap_to(&owner);
12500 let community = create_community(&bed.relay, "Gated", bed.relays.clone(), None).await.unwrap();
12501 create_private_channel(&bed.relay, &community, "mods").await.unwrap();
12502 let rid = "c1".repeat(32);
12503 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12504 publish_grant(&bed.relay, &community, &owner.keys, &moderator.keys.public_key(), vec![rid], 1).await;
12505 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12506 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
12507 let bundle_json = serde_json::to_string(&bundle).unwrap();
12508
12509 bed.swap_to(&moderator);
12510 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
12511 let _ = follow_control(&bed.relay, &joined).await;
12512 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
12513 let target = Keys::generate().public_key();
12514 let err = sever_banned_private_reads(&bed.relay, &joined, &[(target, Vec::new())]).await.unwrap_err();
12515 assert!(err.contains("permission"), "refused at the gate, not mid-rotation: {err}");
12516 }
12517
12518 #[tokio::test]
12519 async fn set_banlist_refuses_an_author_the_fold_would_reject() {
12520 let (bed, owner, member) = TestBed::new();
12524 bed.swap_to(&owner);
12525 let community = create_community(&bed.relay, "Soap", bed.relays.clone(), None).await.unwrap();
12526 let rid = "c2".repeat(32);
12527 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12528 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12529 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
12530 let bundle_json = serde_json::to_string(&bundle).unwrap();
12531
12532 bed.swap_to(&member);
12534 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
12535 let _ = follow_control(&bed.relay, &joined).await;
12536 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
12537 let target = Keys::generate().public_key().to_hex();
12538 let err = set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap_err();
12539 assert!(err.contains("BAN"), "refused for the missing bit: {err}");
12540 assert!(
12541 crate::db::community::get_community_banlist(&crate::simd::hex::bytes_to_hex_32(&joined.id().0)).unwrap().is_empty(),
12542 "no phantom echo cached"
12543 );
12544
12545 bed.swap_to(&owner);
12548 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
12549 let peer = Keys::generate().public_key();
12550 publish_grant(&bed.relay, &community, &owner.keys, &peer, vec![rid], 2).await;
12551 bed.swap_to(&member);
12552 let _ = follow_control(&bed.relay, &joined).await;
12553 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
12554 let err = set_banlist(&bed.relay, &joined, &[peer.to_hex()]).await.unwrap_err();
12555 assert!(err.contains("outrank"), "an equal is not actionable: {err}");
12556 set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap();
12557 }
12558
12559 #[tokio::test]
12560 async fn metadata_and_link_minting_refuse_unauthorized_authors() {
12561 let (bed, owner, member) = TestBed::new();
12564 bed.swap_to(&owner);
12565 let community = create_community(&bed.relay, "Locked", bed.relays.clone(), None).await.unwrap();
12566 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12567 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
12568 let bundle_json = serde_json::to_string(&bundle).unwrap();
12569
12570 bed.swap_to(&member);
12571 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
12572 let _ = follow_control(&bed.relay, &joined).await;
12573 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
12574 let meta = control::CommunityMetadata {
12575 name: "Hijacked".into(),
12576 description: None,
12577 relays: vec![],
12578 icon: None,
12579 banner: None,
12580 custom: None,
12581 extra: Default::default(),
12582 };
12583 assert!(edit_community_metadata(&bed.relay, &joined, &meta).await.is_err(), "metadata edit refused");
12584 assert!(
12585 mint_public_link(&bed.relay, &joined, "https://vectorapp.io/i/", None, None).await.is_err(),
12586 "link minting refused"
12587 );
12588 }
12589
12590 #[tokio::test]
12591 async fn a_vended_key_parks_until_the_fold_proves_the_grant_then_adopts() {
12592 let (bed, owner, member) = TestBed::new();
12596 bed.swap_to(&owner);
12597 let community = create_community(&bed.relay, "Vend", bed.relays.clone(), None).await.unwrap();
12598 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
12599 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12600 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12601 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12602 let real_key = held.channel(&priv_id).unwrap().key.unwrap();
12603 let owner_hex = community.owner().unwrap().to_hex();
12604
12605 bed.swap_to(&member);
12608 let me = member.keys.public_key().to_hex();
12609 let mut member_view = held.clone();
12612 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
12613 c.key = None;
12614 c.epoch = Epoch(0);
12615 }
12616
12617 let empty = crate::community::roles::CommunityRoles::default();
12620 assert!(matches!(
12621 judge_channel_key_vend(&member_view, &empty, &priv_id, Epoch(1), &owner_hex),
12622 VendVerdict::Park(_)
12623 ));
12624
12625 let mut public_view = member_view.clone();
12627 if let Some(c) = public_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
12628 c.private = false;
12629 }
12630 assert!(matches!(
12631 judge_channel_key_vend(&public_view, &empty, &priv_id, Epoch(1), &owner_hex),
12632 VendVerdict::Refuse(_)
12633 ));
12634
12635 assert!(matches!(
12637 judge_channel_key_vend(&member_view, &empty, &ChannelId([0x77; 32]), Epoch(1), &owner_hex),
12638 VendVerdict::Park(_)
12639 ));
12640
12641 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
12644 crate::db::community::set_community_roles(&cid_hex, &empty, 0).unwrap();
12645 crate::db::community::save_community_v2(&member_view).unwrap();
12646 let session = crate::db::current_session();
12647 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12648 assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "unprovable vend adopts nothing");
12649 assert_eq!(
12650 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
12651 1,
12652 "and stays parked for the next fold"
12653 );
12654
12655 let access = crate::community::roles::Role {
12657 role_id: "44".repeat(32),
12658 name: "mods".into(),
12659 position: u32::MAX - 1,
12660 permissions: crate::community::roles::Permissions::empty(),
12661 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
12662 color: 0,
12663 };
12664 let folded = crate::community::roles::CommunityRoles {
12665 grants: vec![crate::community::roles::MemberGrant { member: me.clone(), role_ids: vec![access.role_id.clone()] }],
12666 roles: vec![access],
12667 };
12668 crate::db::community::set_community_roles(&cid_hex, &folded, 1).unwrap();
12669 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12670 let adopted = absorb_parked_channel_keys(&reloaded, &session);
12671 assert_eq!(adopted.len(), 1, "the re-judge adopts once the grant folds");
12672
12673 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12674 let ch = after.channel(&priv_id).unwrap();
12675 assert_eq!(ch.key, Some(real_key), "adopted the vended key");
12676 assert_eq!(ch.epoch, Epoch(1));
12677 assert!(
12678 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
12679 "and the park is discharged"
12680 );
12681 }
12682
12683 #[tokio::test]
12684 async fn a_vend_from_an_unentitled_sender_is_never_seated_but_survives_for_the_real_one() {
12685 let (bed, owner, member) = TestBed::new();
12691 bed.swap_to(&owner);
12692 let community = create_community(&bed.relay, "Vendors", bed.relays.clone(), None).await.unwrap();
12693 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
12694 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12695 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12696 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12697 let owner_hex = community.owner().unwrap().to_hex();
12698
12699 bed.swap_to(&member);
12700 let mut member_view = held.clone();
12701 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
12702 c.key = None;
12703 c.epoch = Epoch(0);
12704 }
12705 crate::db::community::save_community_v2(&member_view).unwrap();
12706
12707 let access = crate::community::roles::Role {
12710 role_id: "66".repeat(32),
12711 name: "mods".into(),
12712 position: u32::MAX - 1,
12713 permissions: crate::community::roles::Permissions::empty(),
12714 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
12715 color: 0,
12716 };
12717 let roster = crate::community::roles::CommunityRoles {
12718 grants: vec![crate::community::roles::MemberGrant {
12719 member: member.keys.public_key().to_hex(),
12720 role_ids: vec![access.role_id.clone()],
12721 }],
12722 roles: vec![access],
12723 };
12724 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
12725
12726 let stranger_hex = Keys::generate().public_key().to_hex();
12727 let junk = [0x11; 32];
12728 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &junk, &stranger_hex).unwrap();
12729
12730 let session = crate::db::current_session();
12731 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12732 assert!(
12733 absorb_parked_channel_keys(&reloaded, &session).is_empty(),
12734 "an unentitled vendor seats nothing"
12735 );
12736 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12737 assert_eq!(after.channel(&priv_id).unwrap().key, None, "the channel is still keyless");
12738 assert_eq!(
12739 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
12740 1,
12741 "parked, not refused — the vendor's own entitlement may simply not have folded here yet"
12742 );
12743
12744 let real = [0x5a; 32];
12746 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &real, &owner_hex).unwrap();
12747 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12748 assert_eq!(
12749 absorb_parked_channel_keys(&reloaded, &session).len(),
12750 1,
12751 "the genuine vend is seated — a stranger cannot occupy the slot"
12752 );
12753 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12754 assert_eq!(after.channel(&priv_id).unwrap().key, Some(real), "and it is the ENTITLED vendor's key");
12755 assert!(
12756 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
12757 "both candidates discharge once one is seated"
12758 );
12759 }
12760
12761 #[tokio::test]
12762 async fn a_vend_at_epoch_zero_is_adopted_onto_a_keyless_channel() {
12763 let (bed, owner, member) = TestBed::new();
12768 bed.swap_to(&owner);
12769 let community = create_community(&bed.relay, "EpochZero", bed.relays.clone(), None).await.unwrap();
12770 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
12771 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12772 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12773 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12774 let vended = [0x5a; 32];
12775 let owner_hex = community.owner().unwrap().to_hex();
12776
12777 bed.swap_to(&member);
12778 let mut member_view = held.clone();
12780 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
12781 c.key = None;
12782 c.epoch = Epoch(0);
12783 }
12784 crate::db::community::save_community_v2(&member_view).unwrap();
12785
12786 let access = crate::community::roles::Role {
12788 role_id: "77".repeat(32),
12789 name: "mods".into(),
12790 position: u32::MAX - 1,
12791 permissions: crate::community::roles::Permissions::empty(),
12792 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
12793 color: 0,
12794 };
12795 let roster = crate::community::roles::CommunityRoles {
12796 grants: vec![crate::community::roles::MemberGrant {
12797 member: member.keys.public_key().to_hex(),
12798 role_ids: vec![access.role_id.clone()],
12799 }],
12800 roles: vec![access],
12801 };
12802 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
12803 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &vended, &owner_hex).unwrap();
12804
12805 let session = crate::db::current_session();
12806 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12807 let adopted = absorb_parked_channel_keys(&reloaded, &session);
12808 assert_eq!(adopted.len(), 1, "an epoch-0 vend onto a keyless channel is adopted");
12809
12810 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12811 let ch = after.channel(&priv_id).unwrap();
12812 assert_eq!(ch.key, Some(vended), "the key actually landed on the row");
12813 assert_eq!(ch.epoch, Epoch(0), "at the epoch the vendor named");
12814 assert!(
12815 !after.channel_read_coords(ch).is_empty(),
12816 "and the channel is readable — the whole point"
12817 );
12818 assert!(
12819 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
12820 "the park is discharged"
12821 );
12822 }
12823
12824 #[tokio::test]
12825 async fn a_wildly_ahead_vend_epoch_is_refused_not_seated() {
12826 let (bed, owner, member) = TestBed::new();
12832 bed.swap_to(&owner);
12833 let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
12834 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
12835 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12836 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12837 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12838 let owner_hex = community.owner().unwrap().to_hex();
12839
12840 bed.swap_to(&member);
12841 let mut member_view = held.clone();
12842 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
12843 c.key = None;
12844 c.epoch = Epoch(0);
12845 }
12846 crate::db::community::save_community_v2(&member_view).unwrap();
12847 let access = crate::community::roles::Role {
12848 role_id: "99".repeat(32),
12849 name: "mods".into(),
12850 position: u32::MAX - 1,
12851 permissions: crate::community::roles::Permissions::empty(),
12852 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
12853 color: 0,
12854 };
12855 let roster = crate::community::roles::CommunityRoles {
12856 grants: vec![crate::community::roles::MemberGrant {
12857 member: member.keys.public_key().to_hex(),
12858 role_ids: vec![access.role_id.clone()],
12859 }],
12860 roles: vec![access],
12861 };
12862 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
12863 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12864
12865 assert!(matches!(
12867 judge_channel_key_vend(&reloaded, &roster, &priv_id, Epoch(1 << 40), &owner_hex),
12868 VendVerdict::Refuse(_)
12869 ));
12870 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1 << 40, &[0xEE; 32], &owner_hex).unwrap();
12872 let session = crate::db::current_session();
12873 assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "a poison epoch adopts nothing");
12874 assert!(
12875 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
12876 "and the row is discharged rather than parked forever"
12877 );
12878 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12880 assert_eq!(after.channel(&priv_id).unwrap().epoch, Epoch(0), "head never advanced");
12881 assert!(matches!(
12882 judge_channel_key_vend(&after, &roster, &priv_id, Epoch(1), &owner_hex),
12883 VendVerdict::Accept
12884 ));
12885 }
12886
12887 #[tokio::test]
12888 async fn a_channel_rename_lands_locally_without_waiting_for_the_fold() {
12889 let (_tmp, _guard, _owner) = init_test_db();
12892 let relay = MemoryRelay::new();
12893 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
12894 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
12895 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12896 let key_before = held.channel(&priv_id).unwrap().key;
12897
12898 let mut meta = held.channel(&priv_id).unwrap().metadata();
12899 meta.name = "staff".into();
12900 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
12901
12902 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12903 let ch = after.channel(&priv_id).unwrap();
12904 assert_eq!(ch.name, "staff", "the rename is visible immediately");
12905 assert!(ch.private, "and privacy survives the edit");
12906 assert_eq!(ch.key, key_before, "as does the key — a rename is not a rotation");
12907 }
12908
12909 #[tokio::test]
12910 async fn a_channel_rename_carries_its_companion_access_role() {
12911 let (_tmp, _guard, _owner) = init_test_db();
12912 let relay = MemoryRelay::new();
12913 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
12914 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
12915 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12916 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12917
12918 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12919 let before = roster.channel_roles(&chan_hex);
12920 assert_eq!(before.len(), 1, "one companion role, minted at create");
12921 assert_eq!(before[0].name, "mods", "named after the channel it gates");
12922 let role_id = before[0].role_id.clone();
12923
12924 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12925 let mut meta = held.channel(&priv_id).unwrap().metadata();
12926 meta.name = "staff".into();
12927 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
12928
12929 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12930 let after = roster.channel_roles(&chan_hex);
12931 assert_eq!(after.len(), 1, "renamed in place, never duplicated");
12932 assert_eq!(after[0].role_id, role_id, "a rename is a versioned edit of the same id");
12933 assert_eq!(after[0].name, "staff", "the access role followed the channel");
12934 assert_eq!(
12935 after[0].permissions,
12936 crate::community::roles::Permissions::empty(),
12937 "and still confers read access, never authority"
12938 );
12939 }
12940
12941 #[tokio::test]
12942 async fn a_customised_access_role_name_survives_a_channel_rename() {
12943 let (_tmp, _guard, _owner) = init_test_db();
12946 let relay = MemoryRelay::new();
12947 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
12948 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
12949 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12950 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12951
12952 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12953 let mut role = roster.channel_roles(&chan_hex)[0].clone();
12954 role.name = "Lab Insiders".into();
12955 set_role(&relay, &community, &role).await.unwrap();
12956 merge_local_roster(&cid_hex, Some(&role), None);
12957
12958 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12959 let mut meta = held.channel(&priv_id).unwrap().metadata();
12960 meta.name = "staff".into();
12961 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
12962
12963 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12964 assert_eq!(
12965 roster.channel_roles(&chan_hex)[0].name,
12966 "Lab Insiders",
12967 "a deliberate name is left alone"
12968 );
12969 }
12970
12971 #[tokio::test]
12972 async fn a_squatted_park_row_cannot_suppress_the_genuine_vend() {
12973 let (bed, owner, member) = TestBed::new();
12979 bed.swap_to(&owner);
12980 let community = create_community(&bed.relay, "Squat", bed.relays.clone(), None).await.unwrap();
12981 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
12982 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12983 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12984 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12985 let real_key = held.channel(&priv_id).unwrap().key.unwrap();
12986 let owner_hex = community.owner().unwrap().to_hex();
12987
12988 bed.swap_to(&member);
12989 let mut member_view = held.clone();
12990 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
12991 c.key = None;
12992 c.epoch = Epoch(0);
12993 }
12994 crate::db::community::save_community_v2(&member_view).unwrap();
12995 let access = crate::community::roles::Role {
12996 role_id: "aa".repeat(32),
12997 name: "mods".into(),
12998 position: u32::MAX - 1,
12999 permissions: crate::community::roles::Permissions::empty(),
13000 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
13001 color: 0,
13002 };
13003 let roster = crate::community::roles::CommunityRoles {
13004 grants: vec![crate::community::roles::MemberGrant {
13005 member: member.keys.public_key().to_hex(),
13006 role_ids: vec![access.role_id.clone()],
13007 }],
13008 roles: vec![access],
13009 };
13010 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
13011
13012 let stranger = Keys::generate().public_key().to_hex();
13014 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 9, &[0xBA; 32], &stranger).unwrap();
13015 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
13017 assert_eq!(
13018 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
13019 2,
13020 "the squatter never displaces the genuine vend — both are candidates"
13021 );
13022
13023 let session = crate::db::current_session();
13024 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13025 let adopted = absorb_parked_channel_keys(&reloaded, &session);
13026 assert_eq!(adopted.len(), 1, "exactly one adoption");
13027
13028 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13029 let ch = after.channel(&priv_id).unwrap();
13030 assert_eq!(ch.key, Some(real_key), "the OWNER's key won, not the squatter's");
13031 assert_eq!(ch.epoch, Epoch(1), "at the genuine epoch");
13032 assert!(
13033 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
13034 "and every candidate for the channel is discharged"
13035 );
13036 }
13037
13038 #[tokio::test]
13039 async fn revoking_without_a_folded_access_role_refuses_instead_of_evicting_everyone() {
13040 let (_tmp, _guard, _owner) = init_test_db();
13045 let relay = MemoryRelay::new();
13046 let community = create_community(&relay, "NoRole", vec!["wss://r".into()], None).await.unwrap();
13047 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
13048 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13049 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13050 let before = held.channel(&priv_id).unwrap().epoch;
13051
13052 crate::db::community::set_community_roles(&cid_hex, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
13056 let mut blind = held.clone();
13057 blind.relays = vec!["wss://empty".into()];
13058 let err = revoke_channel_access(&relay, &blind, &priv_id, &Keys::generate().public_key())
13059 .await
13060 .unwrap_err();
13061 assert!(err.contains("has not folded"), "refuses with a retryable reason: {err}");
13062
13063 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13064 assert_eq!(after.channel(&priv_id).unwrap().epoch, before, "and rotates nothing");
13065 }
13066
13067 async fn publish_base_rotation(
13074 relay: &MemoryRelay,
13075 community: &CommunityV2,
13076 rotator: &Keys,
13077 recipients: &[PublicKey],
13078 new_root: &[u8; 32],
13079 prev_key: &[u8; 32],
13080 ) {
13081 let new_epoch = Epoch(community.root_epoch.0 + 1);
13082 let prev_epoch = community.root_epoch;
13083 let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
13084 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
13085 let blobs: Vec<_> = recipients
13086 .iter()
13087 .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
13088 .collect();
13089 let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000, my_authority_citation(community, &rotator.public_key()).as_ref()).unwrap();
13090 for e in &events {
13091 relay.publish(e, &community.relays).await.unwrap();
13092 }
13093 }
13094
13095 fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
13097 community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
13098 crate::db::community::save_community_v2(community).unwrap();
13099 }
13100
13101 #[tokio::test]
13102 async fn follow_rekeys_is_a_noop_without_rotations() {
13103 let (_tmp, _guard, _owner) = init_test_db();
13104 let relay = MemoryRelay::new();
13105 let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
13106 let session = crate::db::current_session();
13107 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
13108 assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
13109 }
13110
13111 #[tokio::test]
13112 async fn follow_rekeys_adopts_an_owner_base_rotation() {
13113 let (_tmp, _guard, owner) = init_test_db();
13114 let relay = MemoryRelay::new();
13115 let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
13116 let new_root = [0xB1; 32];
13117 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
13119
13120 let session = crate::db::current_session();
13121 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
13122 assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
13123 assert_eq!(updated.community_root, new_root, "adopted the fresh root");
13124 let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
13126 let general = updated.channels[0].id;
13127 let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
13128 assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
13129 }
13130
13131 #[tokio::test]
13132 async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
13133 let (_tmp, _guard, owner) = init_test_db();
13134 let relay = MemoryRelay::new();
13135 let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
13136 let priv_id = ChannelId([0x33; 32]);
13137 add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
13138
13139 let new_key = [0x55; 32];
13141 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
13142 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
13143 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();
13144 let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None).unwrap();
13145 for e in &events {
13146 relay.publish(e, &community.relays).await.unwrap();
13147 }
13148
13149 let session = crate::db::current_session();
13150 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
13151 let ch = updated.channel(&priv_id).unwrap();
13152 assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
13153 assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
13154 assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
13155 }
13156
13157 #[tokio::test]
13158 async fn follow_rekeys_ignores_a_non_owner_rotation() {
13159 let (_tmp, _guard, _owner) = init_test_db();
13162 let relay = MemoryRelay::new();
13163 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
13164 let rogue = Keys::generate();
13165 publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
13166
13167 let session = crate::db::current_session();
13168 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
13169 assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
13170 }
13171
13172 #[tokio::test]
13173 async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
13174 let (_tmp, _guard, owner) = init_test_db();
13177 let relay = MemoryRelay::new();
13178 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
13179 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
13181
13182 let session = crate::db::current_session();
13183 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
13184 assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
13185 }
13186
13187 #[tokio::test]
13188 async fn follow_rekeys_holds_on_an_incomplete_rotation() {
13189 let (_tmp, _guard, owner) = init_test_db();
13192 let relay = MemoryRelay::new();
13193 let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
13194 let new_epoch = Epoch(1);
13195 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
13196 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
13197 let other = Keys::generate();
13199 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
13200 let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None).unwrap();
13201 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
13202 relay.publish(&wrap, &community.relays).await.unwrap();
13203
13204 let session = crate::db::current_session();
13205 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
13206 assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
13207 }
13208
13209 #[tokio::test]
13210 async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
13211 let (bed, owner, member) = TestBed::new();
13215 bed.swap_to(&owner);
13216 let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
13217 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
13218
13219 bed.swap_to(&member);
13220 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
13221 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
13222
13223 bed.swap_to(&owner);
13225 let stranger = Keys::generate();
13226 publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
13227
13228 bed.swap_to(&member);
13230 let session = crate::db::current_session();
13231 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
13232 assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
13233 assert!(follow.updated.is_none(), "a removed member adopts nothing");
13234 }
13235
13236 #[tokio::test]
13237 async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
13238 let (_tmp, _guard, owner) = init_test_db();
13244 let relay = MemoryRelay::new();
13245 let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
13246 let root0 = community.community_root;
13247 let priv_id = ChannelId([0x33; 32]);
13248 let key1 = [0x44; 32];
13249 add_private_channel(&mut community, priv_id, key1, Epoch(1));
13250
13251 let key2 = [0x55; 32];
13254 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
13255 let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
13256 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();
13257 for e in rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &prev_commit, &[blob], 2_000, None).unwrap() {
13258 relay.publish(&e, &community.relays).await.unwrap();
13259 }
13260
13261 community.community_root = [0xB7; 32];
13265 community.root_epoch = Epoch(1);
13266 crate::db::community::save_community_v2(&community).unwrap();
13267
13268 let session = crate::db::current_session();
13269 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
13270 let ch = updated.channel(&priv_id).unwrap();
13271 assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
13272 assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
13273 }
13274
13275 #[tokio::test]
13276 async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
13277 let (_tmp, _guard, owner) = init_test_db();
13282 let relay = MemoryRelay::new();
13283 let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
13284 let priv_id = ChannelId([0x66; 32]);
13285 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() });
13286 crate::db::community::save_community_v2(&community).unwrap();
13287 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13288 assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
13289
13290 let stranger = Keys::generate();
13292 let key1 = [0x71; 32];
13293 let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
13294 let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
13295 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();
13296 for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None).unwrap() {
13297 relay.publish(&e, &community.relays).await.unwrap();
13298 }
13299 let key2 = [0x72; 32];
13302 let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
13303 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
13304 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();
13305 for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None).unwrap() {
13306 relay.publish(&e, &community.relays).await.unwrap();
13307 }
13308
13309 let session = crate::db::current_session();
13312 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
13313 let ch = updated.channel(&priv_id).unwrap();
13314 assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
13315 assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
13316 }
13317
13318 #[tokio::test]
13319 async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
13320 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
13324 let (_tmp, _guard, _owner) = init_test_db();
13325 let relay = MemoryRelay::new();
13326 let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
13327 let priv_id = ChannelId([0x88; 32]);
13328 let key1 = [0x91; 32];
13329 add_private_channel(&mut community, priv_id, key1, Epoch(1));
13330
13331 let admin = Keys::generate();
13333 let role = Role::admin("aa".repeat(32));
13334 let roster = CommunityRoles {
13335 roles: vec![role.clone()],
13336 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
13337 };
13338 seed_roster_with_heads(&community, &roster, 1_000);
13339
13340 let key2 = [0x92; 32];
13342 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
13343 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
13344 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
13345 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
13346 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, my_authority_citation(&community, &admin.public_key()).as_ref()).unwrap() {
13347 relay.publish(&e, &community.relays).await.unwrap();
13348 }
13349 let session = crate::db::current_session();
13350 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
13351 assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
13352
13353 let rogue = Keys::generate();
13355 let key3 = [0x93; 32];
13356 let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
13357 let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
13358 let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
13359 for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None).unwrap() {
13360 relay.publish(&e, &updated.relays).await.unwrap();
13361 }
13362 let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
13363 assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
13364 }
13365
13366 #[tokio::test]
13367 async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
13368 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
13373 let (bed, owner, member) = TestBed::new();
13374 bed.swap_to(&owner);
13375 let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
13376
13377 bed.swap_to(&member);
13380 let mut held = community.clone();
13381 let priv_id = ChannelId([0xAB; 32]);
13382 let key1 = [0xA1; 32];
13383 add_private_channel(&mut held, priv_id, key1, Epoch(1));
13384 let peer = Keys::generate();
13385 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13386 let role = Role::admin("bb".repeat(32));
13387 let roster = CommunityRoles {
13388 roles: vec![role.clone()],
13389 grants: vec![
13390 MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
13391 MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
13392 ],
13393 };
13394 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
13395
13396 let key2 = [0xA2; 32];
13398 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
13399 let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
13400 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();
13401 for e in rekey::build_rekey_chunks_local(&peer, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[pb], 2_000, my_authority_citation(&held, &peer.public_key()).as_ref()).unwrap() {
13402 bed.relay.publish(&e, &held.relays).await.unwrap();
13403 }
13404 let session = crate::db::current_session();
13405 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
13406 assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
13407 let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
13408 assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
13409
13410 let key3 = [0xA3; 32];
13412 let stranger = Keys::generate();
13413 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();
13414 for e in rekey::build_rekey_chunks_local(&owner.keys, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[ob], 2_100, None).unwrap() {
13415 bed.relay.publish(&e, &held.relays).await.unwrap();
13416 }
13417 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
13418 let updated = follow.updated.expect("the owner's removal folds");
13419 assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
13420 }
13421
13422 #[tokio::test]
13423 async fn converting_a_public_channel_to_private_is_refused() {
13424 let (_tmp, _guard, _owner) = init_test_db();
13427 let relay = MemoryRelay::new();
13428 let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
13429 let general = community.channels[0].id;
13430 let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
13431 let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
13432 assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
13433 let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
13435 edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
13436 }
13437
13438 async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
13441 let doc = super::super::list::CommunityList {
13442 entries: vec![],
13443 tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
13444 extra: Default::default(),
13445 };
13446 for (i, frag) in super::super::list_frag::fragment(&doc).iter().enumerate() {
13448 let event = super::super::list_frag::build_fragment_event_keys(me, frag, i, now_ms() / 1000).unwrap();
13449 relay.publish(&event, relays).await.unwrap();
13450 }
13451 }
13452
13453 #[tokio::test]
13454 async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
13455 let (_tmp, _guard, me) = init_test_db();
13459 let relay = MemoryRelay::new();
13460 let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
13461 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
13462
13463 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
13465
13466 let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
13468 republish_community_list(&relay, Some(y.id())).await.unwrap();
13469
13470 let list = fetch_fragments(&relay, &x.relays).await.unwrap().unwrap().list;
13472 assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
13473 assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
13474 }
13475
13476 #[tokio::test]
13477 async fn sync_tears_down_a_community_a_sibling_left() {
13478 let (_tmp, _guard, me) = init_test_db();
13481 let relay = MemoryRelay::new();
13482 let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
13483 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
13484 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
13485
13486 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
13487 sync_community_list(&relay, &x.relays).await.unwrap();
13488 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
13489 }
13490
13491 #[tokio::test]
13492 async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
13493 let (_tmp, _guard, me) = init_test_db();
13496 let relay = MemoryRelay::new();
13497 let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
13498 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
13499 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
13501 republish_community_list(&relay, Some(x.id())).await.unwrap();
13503 sync_community_list(&relay, &x.relays).await.unwrap();
13504 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
13505 }
13506
13507 #[tokio::test]
13508 async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
13509 let (_tmp, _guard, _me) = init_test_db();
13512 let good = MemoryRelay::new();
13513 let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
13514 assert!(fetch_fragments(&good, &community.relays).await.unwrap().is_some());
13515
13516 struct FetchErrors;
13518 #[async_trait::async_trait]
13519 impl Transport for FetchErrors {
13520 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
13521 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
13522 panic!("republish must NOT publish when the remote fetch failed");
13523 }
13524 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
13525 Ok(())
13526 }
13527 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
13528 Err("relay unreachable".to_string())
13529 }
13530 }
13531 republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
13533 }
13534
13535 #[tokio::test]
13536 async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
13537 let (_tmp, _guard, owner) = init_test_db();
13543 let relay = MemoryRelay::new();
13544 let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
13545
13546 let lurker = Keys::generate();
13548 let rid = "b1".repeat(32);
13549 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
13550 publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
13551
13552 let members = memberlist(&relay, &community).await.unwrap();
13554 assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
13555
13556 let banned_grantee = Keys::generate();
13558 publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
13559 set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
13560 let members = memberlist(&relay, &community).await.unwrap();
13561 assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
13562 assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
13563
13564 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
13566 assert_eq!(refounded.root_epoch, Epoch(1));
13567 let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
13568 let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
13569 let rotations = rekey::collect_rotations(&chunks);
13570 let lurker_x = lurker.public_key().to_bytes();
13571 let delivered = rotations.iter().any(|r| {
13572 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
13573 });
13574 assert!(delivered, "the Refounding delivered the new root to the granted lurker");
13575 }
13576
13577 #[tokio::test]
13578 async fn the_memberlist_pages_past_a_guestbook_flood() {
13579 let (_tmp, _guard, _owner) = init_test_db();
13583 let relay = MemoryRelay::new();
13584 let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
13585 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
13586
13587 let honest = Keys::generate();
13589 let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
13590 let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
13591 relay.publish(&w, &community.relays).await.unwrap();
13592 for i in 0..600u64 {
13593 let throwaway = Keys::generate();
13594 let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
13595 let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
13596 relay.publish(&w, &community.relays).await.unwrap();
13597 }
13598
13599 let members = memberlist(&relay, &community).await.unwrap();
13600 assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
13601 }
13602
13603 #[tokio::test]
13604 async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
13605 let (_tmp, _guard, owner) = init_test_db();
13610 let relay = MemoryRelay::new();
13611 let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
13612 let new_root = [0xD9; 32];
13613 let new_epoch = Epoch(1);
13614 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
13615
13616 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
13618
13619 let rogue = Keys::generate();
13622 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
13623 for i in 0..260u64 {
13624 let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
13625 let rumor = rekey::build_rekey_rumor(rogue.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 1, 3_000 + i, None).unwrap();
13626 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
13627 relay.publish(&wrap, &community.relays).await.unwrap();
13628 }
13629
13630 let session = crate::db::current_session();
13631 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
13632 assert_eq!(updated.root_epoch, Epoch(1));
13633 assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
13634 }
13635
13636 #[tokio::test]
13637 async fn a_swap_during_create_private_channel_lands_in_the_creating_account() {
13638 let (bed, owner, _member) = TestBed::new();
13647 bed.swap_to(&owner);
13648 let creator = crate::db::current_session();
13649 let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
13650 let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
13651
13652 let swap_relay = SwapMidPublish { inner: MemoryRelay::new() };
13654 create_private_channel(&swap_relay, &community, "ghost").await.unwrap();
13655
13656 let mine = crate::db::with_session(creator, async {
13657 crate::db::community::load_community_v2(community.id()).unwrap().unwrap()
13658 })
13659 .await;
13660 assert_eq!(mine.channels.len(), before + 1, "the channel is minted for the account that asked");
13661 assert!(mine.channels.iter().any(|c| c.name == "ghost"), "and it is the one requested");
13662 }
13663
13664 #[tokio::test]
13665 async fn an_uncited_admin_rotation_is_not_adopted() {
13666 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
13671 let (_tmp, _guard, _owner) = init_test_db();
13672 let relay = MemoryRelay::new();
13673 let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
13674 let priv_id = ChannelId([0x8A; 32]);
13675 let key1 = [0x93; 32];
13676 add_private_channel(&mut community, priv_id, key1, Epoch(1));
13677
13678 let admin = Keys::generate();
13679 let role = Role::admin("cf".repeat(32));
13680 let roster = CommunityRoles {
13681 roles: vec![role.clone()],
13682 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
13683 };
13684 seed_roster_with_heads(&community, &roster, 1_000);
13685
13686 let key2 = [0x94; 32];
13687 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
13688 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
13689 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
13690 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
13691 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None).unwrap() {
13693 relay.publish(&e, &community.relays).await.unwrap();
13694 }
13695
13696 let out = follow_rekeys(&relay, &community, &crate::db::current_session()).await.unwrap();
13697 assert!(out.updated.is_none(), "an uncited rotation is not adopted");
13698
13699 let cited = my_authority_citation(&community, &admin.public_key());
13702 assert!(cited.is_some(), "the seeded head yields a citation");
13703 let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
13704 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob2], 2_100, cited.as_ref()).unwrap() {
13705 relay.publish(&e, &community.relays).await.unwrap();
13706 }
13707 let out = follow_rekeys(&relay, &community, &crate::db::current_session()).await.unwrap();
13708 assert!(out.updated.is_some(), "the cited rotation IS adopted");
13709 }
13710
13711 #[tokio::test]
13712 async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
13713 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
13721 let (_tmp, _guard, _owner) = init_test_db();
13722 let relay = MemoryRelay::new();
13723 let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
13724 let priv_id = ChannelId([0xC0; 32]);
13725 let key1 = [0xC1; 32];
13726 add_private_channel(&mut community, priv_id, key1, Epoch(1));
13727
13728 let (a, b) = (Keys::generate(), Keys::generate());
13730 let role = Role::admin("ce".repeat(32));
13731 let roster = CommunityRoles {
13732 roles: vec![role.clone()],
13733 grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
13734 };
13735 seed_roster_with_heads(&community, &roster, 1_000);
13736
13737 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
13740 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
13741 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
13742 let key_a = [0x0A; 32];
13743 let key_b = [0xFB; 32]; for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
13745 let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
13746 for e in rekey::build_rekey_chunks_local(signer, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, my_authority_citation(&community, &signer.public_key()).as_ref()).unwrap() {
13747 relay.publish(&e, &community.relays).await.unwrap();
13748 }
13749 }
13750
13751 let session = crate::db::current_session();
13752 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
13753 let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
13754 assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
13755
13756 let mut peer = community.clone();
13758 if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
13759 c.key = Some(key1);
13760 c.epoch = Epoch(1);
13761 }
13762 let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
13764 assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
13765 }
13766
13767 #[tokio::test]
13768 async fn create_private_channel_refuses_a_member_without_manage_channels() {
13769 let (bed, owner, member) = TestBed::new();
13772 bed.swap_to(&owner);
13773 let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
13774 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
13775
13776 bed.swap_to(&member);
13777 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
13778 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
13779 let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
13780 assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
13781 let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
13782 assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
13783 }
13784
13785 #[tokio::test]
13788 async fn accept_rejects_a_bundle_with_a_forged_community_root() {
13789 let (bed, owner, member) = TestBed::new();
13793 bed.swap_to(&owner);
13794 let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
13795
13796 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
13797 let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
13798 forged.community_root = fake.clone();
13799 for ch in &mut forged.channels {
13800 ch.key = fake.clone();
13801 }
13802 let attacker = Keys::generate();
13803 let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
13804 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
13805
13806 bed.swap_to(&member);
13807 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
13808 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
13809 assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
13810 assert!(
13811 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
13812 "a rejected join persists nothing"
13813 );
13814 }
13815
13816 #[tokio::test]
13817 async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
13818 let (bed, owner, member) = TestBed::new();
13823 bed.swap_to(&owner);
13824 let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
13825 let general = community.channels[0].id;
13826
13827 let rotated = rotate_view(&community, [0x5A; 32], 1);
13828 let admin = Keys::generate();
13829 publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
13830 publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
13831
13832 bed.swap_to(&member);
13833 let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
13834 let joined = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
13835 assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
13836 }
13837
13838 #[tokio::test]
13839 async fn only_an_actual_join_publishes_a_guestbook_join() {
13840 let (bed, owner, member) = TestBed::new();
13844 bed.swap_to(&owner);
13845 let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
13846
13847 let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
13848 async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
13849 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
13850 relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
13851 }
13852 let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await; bed.swap_to(&member);
13855 let bundle = bundle_of(&community, BundleAudience::Link, None, None, None);
13856 accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
13857 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
13858
13859 accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
13860 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
13861
13862 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13863 crate::db::community::delete_community(&cid_hex).unwrap();
13864 async fn list_id(relay: &MemoryRelay, me: &PublicKey, relays: &[String]) -> Option<String> {
13872 let q = Query { kinds: vec![crate::community::v2::kind::COMMUNITY_LIST_FRAG], authors: vec![me.to_hex()], ..Default::default() };
13873 let mut evs = relay.fetch(&q, relays).await.unwrap_or_default();
13874 evs.sort_by_key(|e| e.created_at.as_secs());
13875 evs.last().map(|e| e.id.to_hex())
13876 }
13877 let my_pk = me_pk().unwrap();
13878 let list_before = list_id(&bed.relay, &my_pk, &bed.relays).await;
13879 accept_bundle(&bed.relay, &bundle, None, false).await.unwrap();
13880 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
13881 assert_eq!(
13882 list_id(&bed.relay, &my_pk, &bed.relays).await,
13883 list_before,
13884 "adopting an entry FROM the list must not republish the list"
13885 );
13886
13887 crate::db::community::delete_community(&cid_hex).unwrap();
13890 accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
13891 assert_ne!(
13892 list_id(&bed.relay, &my_pk, &bed.relays).await,
13893 list_before,
13894 "a real join still records the membership across devices"
13895 );
13896 }
13897
13898 #[tokio::test]
13899 async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
13900 let (bed, owner, member) = TestBed::new();
13903 bed.swap_to(&owner);
13904 let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
13905
13906 let rotated = rotate_view(&community, [0x5B; 32], 1);
13907 let attacker = Keys::generate();
13908 publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
13909
13910 bed.swap_to(&member);
13911 let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
13912 let err = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap_err();
13913 assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
13914 }
13915
13916 #[tokio::test]
13917 async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
13918 let (bed, owner, member) = TestBed::new();
13921 bed.swap_to(&owner);
13922 let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
13923 let general = community.channels[0].id;
13924
13925 let mut fake = community.clone();
13926 fake.community_root = [0x5C; 32]; let admin = Keys::generate();
13928 publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
13929 publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
13930
13931 bed.swap_to(&member);
13932 let bundle = bundle_of(&fake, BundleAudience::Link, None, None, None);
13933 let err = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap_err();
13934 assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
13935 }
13936
13937 #[tokio::test]
13938 async fn follow_control_heals_a_bundle_misclassified_public_channel() {
13939 let (_tmp, _guard, _owner) = init_test_db();
13943 let relay = MemoryRelay::new();
13944 let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
13945 let general = community.channels[0].id;
13946 let mut poisoned = community.clone();
13947 poisoned.channels[0].private = true;
13948 poisoned.channels[0].key = Some([0x66; 32]);
13949 crate::db::community::save_community_v2(&poisoned).unwrap();
13950
13951 let healed = follow_control(&relay, &poisoned).await.unwrap().expect("healed");
13952 let ch = healed.channel(&general).unwrap();
13953 assert!(!ch.private, "the owner's public declaration overrides the bundle");
13954 assert_eq!(ch.key, None, "a healed public channel derives from the root");
13955 }
13956
13957 #[tokio::test]
13958 async fn a_deleted_channel_does_not_resurrect_on_reload() {
13959 let (_tmp, _guard, owner) = init_test_db();
13962 let relay = MemoryRelay::new();
13963 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
13964 let extra = ChannelId([0x77; 32]);
13965 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
13966 let with_extra = follow_control(&relay, &community).await.unwrap().unwrap();
13967 assert!(with_extra.channel(&extra).is_some());
13968 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
13969 let after = follow_control(&relay, &with_extra).await.unwrap().unwrap();
13970 assert!(after.channel(&extra).is_none());
13971
13972 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13973 assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
13974 assert_eq!(reloaded.channels.len(), 1);
13975 }
13976
13977 #[tokio::test]
13978 async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
13979 let (_tmp, _guard, _owner) = init_test_db();
13983 let relay = MemoryRelay::new();
13984 let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
13985 let a_channel = a.channels[0].id;
13986 let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
13987 let b_channel = b.channels[0].id;
13988 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() });
13990
13991 crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
13992 let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
13994 assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
13995 assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
13996 let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
13998 assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
13999 assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
14000 }
14001
14002 struct CappedRelay {
14008 events: Vec<Event>,
14009 cap: usize,
14010 }
14011 #[async_trait::async_trait]
14012 impl Transport for CappedRelay {
14013 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
14014 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
14015 Ok(())
14016 }
14017 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
14018 Ok(())
14019 }
14020 async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
14021 let mut m: Vec<Event> = self
14022 .events
14023 .iter()
14024 .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
14025 .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
14026 .cloned()
14027 .collect();
14028 m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
14030 Ok(m)
14031 }
14032 }
14033
14034 #[tokio::test]
14035 async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
14036 let (_tmp, _guard, _owner) = init_test_db();
14043 let memory = MemoryRelay::new();
14044 let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
14045 let control = control::ControlPlane::of(&community).write_group().unwrap();
14049
14050 let rogue = Keys::generate();
14051 let mut events: Vec<Event> = Vec::new();
14052 for i in 0..FOLLOW_PAGE {
14053 let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
14054 let rumor = control::build_edition_rumor(
14055 rogue.public_key(),
14056 vsk::CHANNEL_METADATA,
14057 &[0xAB; 32],
14058 1,
14059 None,
14060 &content,
14061 9_000,
14062 None,
14063 );
14064 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
14065 events.push(w);
14066 }
14067 let relay = CappedRelay { events, cap: FOLLOW_PAGE };
14068
14069 let err = refound_community(&relay, &community, &[])
14070 .await
14071 .expect_err("a plane that can't be read whole must never be compacted");
14072 assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
14073 }
14074
14075 #[tokio::test]
14076 async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
14077 let (_tmp, _guard, owner) = init_test_db();
14082 let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
14083 let g = control::genesis(&owner, meta, 1_000).unwrap();
14084 let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
14085
14086 let control = control::ControlPlane::of(&community).write_group().unwrap();
14087 let rogue = Keys::generate();
14088 let mut events: Vec<Event> = g.wraps.to_vec();
14089 for i in 0..250u64 {
14090 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
14091 let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
14092 events.push(wrap);
14093 }
14094 let relay = CappedRelay { events, cap: 100 };
14096 let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
14097 assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
14098 }
14099
14100 #[tokio::test]
14101 async fn accept_parked_invite_joins_from_the_stored_bundle() {
14102 let (bed, owner, member) = TestBed::new();
14105 bed.swap_to(&owner);
14106 let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
14107 let general = community.channels[0].id;
14108 send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
14109 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14110 let bundle_json = serde_json::to_string(&bundle).unwrap();
14111 let inviter_hex = owner.keys.public_key().to_hex();
14112
14113 bed.swap_to(&member);
14114 let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
14115 assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
14116 assert!(joined.identity.verify());
14117 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
14118 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
14121 assert!(
14122 crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
14123 "the joiner's control floor is seeded from the join-time fold"
14124 );
14125
14126 bed.swap_to(&owner);
14128 let members = memberlist(&bed.relay, &community).await.unwrap();
14129 assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
14130 }
14131
14132 #[tokio::test]
14133 async fn accept_parked_invite_rejects_a_forged_root() {
14134 let (_tmp, _guard, _owner) = init_test_db();
14138 let relay = MemoryRelay::new();
14139 let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
14140 let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
14141 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
14142 forged.community_root = fake.clone();
14143 for ch in &mut forged.channels {
14144 ch.key = fake.clone();
14145 }
14146 let bundle_json = serde_json::to_string(&forged).unwrap();
14147
14148 let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
14149 assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
14150 }
14151
14152 #[test]
14153 fn v2_and_v1_bundles_are_distinguishable_by_parse() {
14154 let owner = Keys::generate();
14158 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
14159 let hex = crate::simd::hex::bytes_to_hex_32;
14160 let v2 = invite::CommunityInvite {
14161 community_id: hex(&identity.community_id.0),
14162 owner: hex(&identity.owner_xonly),
14163 owner_salt: hex(&identity.owner_salt),
14164 community_root: hex(&[0x11; 32]),
14165 root_epoch: 0,
14166 control_pk: None,
14167 channels: vec![],
14168 relays: vec!["wss://r".into()],
14169 name: "V2".into(),
14170 icon: None,
14171 expires_at: None,
14172 creator_npub: None,
14173 label: None,
14174 extra: Default::default(),
14175 };
14176 let v2_json = serde_json::to_string(&v2).unwrap();
14177 assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
14178 let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
14179 assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
14180 }
14181
14182 #[tokio::test]
14183 async fn verify_rejects_a_cross_community_owner_edition_replay() {
14184 let (_tmp, _guard, owner) = init_test_db();
14190
14191 let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
14193 let x_control = control::split_write_group(&gx.control_root, &gx.community_root, &gx.identity.community_id, Epoch(0));
14194 let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
14195
14196 let t_identity = control::CommunityIdentity::mint(&owner.public_key());
14198 let fake_root = [0xEE; 32];
14199 let t = CommunityV2 {
14200 identity: t_identity,
14201 community_root: fake_root,
14202 root_epoch: Epoch(0),
14203 control_pk: None,
14204 control_root: None,
14205 name: "T".into(),
14206 description: None,
14207 icon: None,
14208 banner: None,
14209 meta_custom: None,
14210 meta_extra: Default::default(),
14211 relays: vec!["wss://r".into()],
14212 channels: vec![],
14213 dissolved: false,
14214 created_at_ms: 0,
14215 };
14216 let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
14219 let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
14220 let relay = MemoryRelay::new();
14221 relay.publish(&replayed, &t.relays).await.unwrap();
14222
14223 let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
14224 assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
14225 }
14226
14227 #[tokio::test]
14235 #[ignore = "hits a real relay over the network"]
14236 async fn live_smoke_create_send_fetch_on_a_real_relay() {
14237 use crate::community::transport::LiveTransport;
14238 use nostr_sdk::prelude::ToBech32;
14239
14240 let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
14241 let relays = vec![relay.clone()];
14242
14243 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
14245 crate::db::close_database();
14246 crate::db::clear_id_caches();
14247 let tmp = tempfile::tempdir().unwrap();
14248 let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
14251 Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
14252 Err(_) => Keys::generate(),
14253 };
14254 let npub = keys.public_key().to_bech32().unwrap();
14255 if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
14258 println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
14259 }
14260 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
14261 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
14262 crate::db::set_current_account(npub.clone()).unwrap();
14263 crate::db::init_database(&npub).unwrap();
14264 crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
14265 crate::state::set_my_public_key(keys.public_key());
14266 println!("[smoke] throwaway identity {npub}");
14267
14268 let client = crate::nostr_client_builder().build();
14270 client.add_managed_relay(relay.as_str()).await.ok();
14271 client.connect().await;
14272 crate::state::set_nostr_client(client);
14273 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
14274
14275 let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
14277 let general = community.channels[0].id;
14278 println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
14279
14280 let text = "hello from a Vector Concord v2 live smoke test";
14281 let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
14282 println!("[smoke] sent message {sent_id}");
14283
14284 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
14286
14287 let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
14288 let texts: Vec<String> = page
14289 .iter()
14290 .filter_map(|f| match &f.event {
14291 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
14292 _ => None,
14293 })
14294 .collect();
14295 println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
14296 assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
14297
14298 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
14300 println!("[smoke] invite link: {}", link.url);
14301 println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
14302 }
14303
14304 #[tokio::test]
14305 async fn chat_ops_react_edit_delete_round_trip() {
14306 let (bed, owner, _member) = TestBed::new();
14307 bed.swap_to(&owner);
14308 let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
14309 let general = community.channels[0].id;
14310 let me_hex = owner.keys.public_key().to_hex();
14311
14312 let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
14313 send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
14314 .await
14315 .unwrap();
14316 send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
14317 send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
14318
14319 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
14320 let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
14321 let mut saw = (false, false, false);
14322 for f in &page {
14323 match &f.event {
14324 ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
14325 assert_eq!(emoji, ":fire:");
14326 assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
14327 saw.0 = true;
14328 }
14329 ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
14330 assert_eq!(new_content, "edited");
14331 saw.1 = true;
14332 }
14333 ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
14334 _ => {}
14335 }
14336 }
14337 assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
14338 }
14339
14340 #[tokio::test]
14341 async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
14342 let (bed, owner, _member) = TestBed::new();
14343 bed.swap_to(&owner);
14344 let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
14345 let general = community.channels[0].id;
14346 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
14347
14348 let mut sub = bed.relay.subscribe(Query {
14350 kinds: vec![stream::KIND_WRAP_EPHEMERAL],
14351 authors: vec![group.pk_hex()],
14352 ..Default::default()
14353 });
14354 send_typing(&bed.relay, &community, &general).await.unwrap();
14355 let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
14356 let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
14357 Ok(ChatEvent::Typing { opened }) => opened,
14358 other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
14359 };
14360
14361 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
14364 assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
14365
14366 assert!(
14368 crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
14369 "ephemeral sends must not retain scrub keys"
14370 );
14371 }
14372
14373 #[tokio::test]
14374 async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
14375 let (bed, owner, _member) = TestBed::new();
14376 bed.swap_to(&owner);
14377 let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
14378 let general = community.channels[0].id;
14379 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
14380
14381 let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
14382
14383 let (keys, outer_hex, relays) =
14386 crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
14387 assert_eq!(relays, community.relays);
14388 let wrap_query = Query {
14389 kinds: vec![stream::KIND_WRAP],
14390 authors: vec![group.pk_hex()],
14391 ..Default::default()
14392 };
14393 let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
14394 let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
14395 assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
14396
14397 let me_hex = owner.keys.public_key().to_hex();
14399 let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
14400 .await
14401 .unwrap();
14402 assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
14403
14404 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
14407 assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
14408 let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
14409 assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
14410 }
14411
14412 #[tokio::test]
14413 async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
14414 let (bed, owner, _member) = TestBed::new();
14415 bed.swap_to(&owner);
14416 let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
14417 let general = community.channels[0].id;
14418 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
14419
14420 let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
14423 crate::db::community::delete_message_key(&id).unwrap();
14424 assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
14425
14426 let mkeys = Keys::generate();
14428 let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
14429 let foreign_id = rumor.id.unwrap().to_hex();
14430 let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
14431 bed.relay.publish(&fw, &community.relays).await.unwrap();
14432
14433 fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
14435 let (keys, _outer, relays) =
14436 crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
14437 assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
14438 assert_eq!(relays, community.relays);
14439
14440 assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
14442
14443 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
14445 let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
14446 assert!(
14447 !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
14448 "healed message scrubbed from the relay"
14449 );
14450 }
14451
14452 #[tokio::test]
14453 async fn send_chat_message_threads_the_reply_and_extra_tags() {
14454 let (bed, owner, _member) = TestBed::new();
14455 bed.swap_to(&owner);
14456 let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
14457 let general = community.channels[0].id;
14458 let me_hex = owner.keys.public_key().to_hex();
14459
14460 let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
14461 let imeta = nostr_sdk::prelude::Tag::custom(
14462 "imeta",
14463 ["url https://e/blob".to_string(), "m image/png".to_string()],
14464 );
14465 let child_id = send_chat_message(
14466 &bed.relay, &community, &general, "child",
14467 Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
14468 )
14469 .await
14470 .unwrap();
14471
14472 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
14473 let child = page
14474 .iter()
14475 .find_map(|f| match &f.event {
14476 ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
14477 _ => None,
14478 })
14479 .expect("the reply message round-trips");
14480 let reply = child.1.as_ref().expect("the reply reference is carried");
14481 assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
14482 assert_eq!(reply.author, Some(owner.keys.public_key()));
14483 assert!(
14484 child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
14485 "the imeta attachment tag rides the rumor verbatim"
14486 );
14487 }
14488
14489 #[tokio::test]
14490 async fn a_kick_needs_kick_authority_and_removes_the_target() {
14491 let (bed, owner, member) = TestBed::new();
14492 bed.swap_to(&owner);
14493 let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
14494
14495 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
14497 let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
14498 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
14499 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14500 let before = memberlist(&bed.relay, &community).await.unwrap();
14501 assert!(before.contains(&member.keys.public_key()), "the join lands first");
14502
14503 bed.swap_to(&member);
14505 let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
14506 assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
14507
14508 bed.swap_to(&owner);
14510 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
14511 let after = memberlist(&bed.relay, &community).await.unwrap();
14512 assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
14513 assert!(after.contains(&owner.keys.public_key()), "the owner remains");
14514 }
14515
14516 #[tokio::test]
14517 async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
14518 let (bed, owner, member) = TestBed::new();
14523 bed.swap_to(&owner);
14524 let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
14525 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14526 let (o, m) = (owner.keys.public_key(), member.keys.public_key());
14527 let join = |at: u64, id: u8| guestbook::GuestbookEvent {
14528 rumor_id: [id; 32],
14529 entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
14530 };
14531 let kick = |at: u64, id: u8| guestbook::GuestbookEvent {
14532 rumor_id: [id; 32],
14533 entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
14534 };
14535
14536 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
14538 assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
14539
14540 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
14542 assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
14543
14544 crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
14546 assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
14547
14548 assert!(
14551 !stored_memberlist(&community).unwrap().contains(&m),
14552 "the memberlist excludes an un-caught-up member — why it can't gate a kick"
14553 );
14554 }
14555
14556 fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
14561 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14562 crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
14563 for g in &roster.grants {
14564 let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
14565 let eid = super::super::derive::grant_locator(community.id(), &m);
14566 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
14567 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
14568 }
14569 }
14570
14571 async fn publish_grant_citing(
14573 relay: &MemoryRelay,
14574 community: &CommunityV2,
14575 signer: &Keys,
14576 member: &PublicKey,
14577 role_ids: Vec<String>,
14578 version: u64,
14579 citation: Option<&crate::community::edition::AuthorityCitation>,
14580 ) {
14581 let group = control::ControlPlane::of(&community).write_group().unwrap();
14582 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
14583 let prev = head_hash_on_relay(relay, community, &eid).await;
14584 let grant = MemberGrant { member: member.to_hex(), role_ids };
14585 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
14586 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
14587 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
14588 relay.publish(&wrap, &community.relays).await.unwrap();
14589 }
14590
14591 #[tokio::test]
14592 async fn an_uncited_admin_edition_is_not_folded_but_a_cited_one_is() {
14593 let (bed, owner, admin) = TestBed::new();
14598 bed.swap_to(&owner);
14599 let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
14600 let admin_pk = admin.keys.public_key();
14601 let rid = "c3".repeat(32);
14602 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
14603 publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
14604
14605 let low_rid = "c4".repeat(32);
14609 let mut low = admin_role(&low_rid, Permissions::admin().0);
14610 low.position = 5;
14611 publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
14612
14613 let bystander = Keys::generate().public_key();
14614 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
14615 let view = fetch_authority(&bed.relay, &community).await;
14616 assert!(
14617 !view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
14618 "an uncited non-owner edition is not folded"
14619 );
14620 assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant folds");
14622
14623 let _ = follow_control(&bed.relay, &community).await;
14626 let entity_id = crate::community::v2::derive::grant_locator(community.id(), &admin_pk.to_bytes());
14627 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14628 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
14629 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
14630 let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
14631 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid], 2, Some(&cite)).await;
14632
14633 let view = fetch_authority(&bed.relay, &community).await;
14634 assert!(
14635 view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
14636 "the same edition WITH its synced citation folds"
14637 );
14638 }
14639
14640 #[tokio::test]
14641 async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
14642 let (bed, owner, member) = TestBed::new();
14647 bed.swap_to(&owner);
14648 let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
14649 let member_pk = member.keys.public_key();
14650 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14651
14652 crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
14654 crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
14655
14656 let join = guestbook::GuestbookEvent {
14658 rumor_id: [9u8; 32],
14659 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
14660 };
14661 assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
14662 assert!(
14663 !stored_memberlist(&community).unwrap().contains(&member_pk),
14664 "while banned, the fold keeps them out"
14665 );
14666
14667 crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
14669 assert!(
14670 stored_memberlist(&community).unwrap().contains(&member_pk),
14671 "after the unban the raced Join makes them a member again"
14672 );
14673 }
14674
14675 #[tokio::test]
14676 async fn a_stale_root_admin_write_is_refused_not_misdirected() {
14677 let (bed, owner, member) = TestBed::new();
14683 bed.swap_to(&owner);
14684 let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
14685 let member_pk = member.keys.public_key();
14686
14687 set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
14688 let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
14689
14690 let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
14692 assert!(err.contains("re-founded"), "unban: {err}");
14693 let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
14695 assert!(err.contains("re-founded"), "invite: {err}");
14696 let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
14698 assert!(err.contains("re-founded"), "kick: {err}");
14699
14700 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14702 set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
14703 let view = fetch_authority(&bed.relay, &fresh).await;
14704 assert!(view.banned.is_empty(), "the retried unban actually unbans");
14705 }
14706
14707 struct YieldyRelay<'a>(&'a MemoryRelay);
14712 #[async_trait::async_trait]
14713 impl Transport for YieldyRelay<'_> {
14714 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
14715 tokio::task::yield_now().await;
14716 self.0.publish(event, relays).await
14717 }
14718 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
14719 tokio::task::yield_now().await;
14720 self.0.fetch(query, relays).await
14721 }
14722 async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
14723 tokio::task::yield_now().await;
14724 self.0.fetch_plane(plane, query, relays).await
14725 }
14726 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
14727 tokio::task::yield_now().await;
14728 self.0.publish_durable(event, relays).await
14729 }
14730 }
14731
14732 #[tokio::test]
14733 async fn concurrent_bans_compose_instead_of_erasing_each_other() {
14734 let (bed, owner, _member) = TestBed::new();
14739 bed.swap_to(&owner);
14740 let community = create_community(&bed.relay, "Wave", bed.relays.clone(), None).await.unwrap();
14741 let a = Keys::generate().public_key();
14742 let b = Keys::generate().public_key();
14743
14744 let yieldy = YieldyRelay(&bed.relay);
14745 let (wave_a, wave_b) = ([a], [b]);
14746 let (ra, rb) = tokio::join!(
14747 set_members_banned(&yieldy, community.id(), &wave_a, true),
14748 set_members_banned(&yieldy, community.id(), &wave_b, true),
14749 );
14750 ra.unwrap();
14751 rb.unwrap();
14752
14753 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14754 assert_eq!(fresh.root_epoch, Epoch(2), "each single ban still performed its own read cut");
14755 let view = fetch_authority(&bed.relay, &fresh).await;
14756 assert!(
14757 view.banned.contains(&a.to_hex()) && view.banned.contains(&b.to_hex()),
14758 "both racing bans landed: {:?}",
14759 view.banned
14760 );
14761 }
14762
14763 #[tokio::test]
14764 async fn bans_stacked_behind_a_rotation_coalesce_into_one_read_cut() {
14765 let (bed, owner, _member) = TestBed::new();
14770 bed.swap_to(&owner);
14771 let community = create_community(&bed.relay, "Stack", bed.relays.clone(), None).await.unwrap();
14772 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14773 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
14774 let (a, b, c, d) = (
14775 Keys::generate().public_key(),
14776 Keys::generate().public_key(),
14777 Keys::generate().public_key(),
14778 Keys::generate().public_key(),
14779 );
14780
14781 let yieldy = YieldyRelay(&bed.relay);
14782 let (wa, wb, wc, wd) = ([a], [b], [c], [d]);
14783 let (ra, rb, rc, rd) = tokio::join!(
14786 set_members_banned(&yieldy, community.id(), &wa, true),
14787 set_members_banned(&yieldy, community.id(), &wb, true),
14788 set_members_banned(&yieldy, community.id(), &wc, true),
14789 set_members_banned(&yieldy, community.id(), &wd, true),
14790 );
14791 ra.unwrap();
14792 rb.unwrap();
14793 rc.unwrap();
14794 rd.unwrap();
14795
14796 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14797 assert_eq!(fresh.root_epoch, Epoch(2), "leader rotation + ONE coalesced rotation, never four");
14798 let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
14799 assert_eq!(version, 2, "two editions total: the leader's and the coalesced drain's");
14800 let view = fetch_authority(&bed.relay, &fresh).await;
14801 for pk in [a, b, c, d] {
14802 assert!(view.banned.contains(&pk.to_hex()), "{} missing from the fold", pk.to_hex());
14803 }
14804 }
14805
14806 #[tokio::test]
14807 async fn a_stacked_ban_and_unban_coalesce_in_arrival_order() {
14808 let (bed, owner, _member) = TestBed::new();
14812 bed.swap_to(&owner);
14813 let community = create_community(&bed.relay, "Net", bed.relays.clone(), None).await.unwrap();
14814 let x = Keys::generate().public_key();
14815 let y = Keys::generate().public_key();
14816
14817 let yieldy = YieldyRelay(&bed.relay);
14818 let (wx, wy) = ([x], [y]);
14819 let (rx1, ry, rx2) = tokio::join!(
14820 set_members_banned(&yieldy, community.id(), &wx, true), set_members_banned(&yieldy, community.id(), &wy, true), set_members_banned(&yieldy, community.id(), &wx, false), );
14824 rx1.unwrap();
14825 ry.unwrap();
14826 rx2.unwrap();
14827
14828 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14829 assert_eq!(fresh.root_epoch, Epoch(2), "X's rotation + one coalesced rotation for Y");
14830 let view = fetch_authority(&bed.relay, &fresh).await;
14831 assert!(!view.banned.contains(&x.to_hex()), "the stacked unban netted X out");
14832 assert!(view.banned.contains(&y.to_hex()), "Y's stacked ban landed");
14833 }
14834
14835 #[tokio::test]
14836 async fn a_second_member_folds_every_stacked_ban() {
14837 let (bed, owner, member) = TestBed::new();
14842 bed.swap_to(&owner);
14843 let community = create_community(&bed.relay, "Agree", bed.relays.clone(), None).await.unwrap();
14844 let a = Keys::generate().public_key();
14845 let b = Keys::generate().public_key();
14846
14847 let yieldy = YieldyRelay(&bed.relay);
14848 let (wa, wb) = ([a], [b]);
14849 let (ra, rb) = tokio::join!(
14850 set_members_banned(&yieldy, community.id(), &wa, true),
14851 set_members_banned(&yieldy, community.id(), &wb, true),
14852 );
14853 ra.unwrap();
14854 rb.unwrap();
14855
14856 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14858 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14859 let bundle_json = serde_json::to_string(&bundle).unwrap();
14860
14861 bed.swap_to(&member);
14862 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14863 let _ = follow_control(&bed.relay, &joined).await;
14864 let view = fetch_authority(&bed.relay, &joined).await;
14865 assert!(
14866 view.banned.contains(&a.to_hex()) && view.banned.contains(&b.to_hex()),
14867 "an independent reader folds BOTH stacked bans: {:?}",
14868 view.banned
14869 );
14870 }
14871
14872 #[tokio::test]
14873 async fn a_net_zero_batch_publishes_no_edition() {
14874 let (bed, owner, _member) = TestBed::new();
14877 bed.swap_to(&owner);
14878 let community = create_community(&bed.relay, "Noop", bed.relays.clone(), None).await.unwrap();
14879 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14880 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
14881 let x = Keys::generate().public_key();
14882
14883 let decoy = Keys::generate().public_key();
14886 let yieldy = YieldyRelay(&bed.relay);
14887 let (wd, wx) = ([decoy], [x]);
14888 let (rd, r1, r2) = tokio::join!(
14889 set_members_banned(&yieldy, community.id(), &wd, true),
14890 set_members_banned(&yieldy, community.id(), &wx, true),
14891 set_members_banned(&yieldy, community.id(), &wx, false),
14892 );
14893 rd.unwrap();
14894 r1.unwrap();
14895 r2.unwrap();
14896
14897 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14898 assert_eq!(fresh.root_epoch, Epoch(1), "only the decoy's rotation — the net-zero drain rotated nothing");
14899 let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
14900 assert_eq!(version, 1, "only the decoy's edition — the net-zero drain published nothing");
14901 let view = fetch_authority(&bed.relay, &fresh).await;
14902 assert!(!view.banned.contains(&x.to_hex()));
14903 assert!(view.banned.contains(&decoy.to_hex()));
14904 }
14905
14906 struct BrokenPublish<'a>(&'a MemoryRelay);
14908 #[async_trait::async_trait]
14909 impl Transport for BrokenPublish<'_> {
14910 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
14911 tokio::task::yield_now().await;
14912 Err("relay refused".to_string())
14913 }
14914 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
14915 tokio::task::yield_now().await;
14916 self.0.fetch(query, relays).await
14917 }
14918 async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
14919 tokio::task::yield_now().await;
14920 self.0.fetch_plane(plane, query, relays).await
14921 }
14922 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
14923 tokio::task::yield_now().await;
14924 Err("relay refused".to_string())
14925 }
14926 }
14927
14928 #[tokio::test]
14929 async fn a_failed_publish_answers_every_stacked_caller_and_never_hangs() {
14930 let (bed, owner, _member) = TestBed::new();
14934 bed.swap_to(&owner);
14935 let community = create_community(&bed.relay, "Dead", bed.relays.clone(), None).await.unwrap();
14936 let a = Keys::generate().public_key();
14937 let b = Keys::generate().public_key();
14938
14939 let broken = BrokenPublish(&bed.relay);
14940 let (wa, wb) = ([a], [b]);
14941 let (ra, rb) = tokio::time::timeout(
14942 std::time::Duration::from_secs(30),
14943 async {
14944 tokio::join!(
14945 set_members_banned(&broken, community.id(), &wa, true),
14946 set_members_banned(&broken, community.id(), &wb, true),
14947 )
14948 },
14949 )
14950 .await
14951 .expect("stacked callers must resolve, never hang");
14952 assert!(ra.is_err(), "the leader surfaced the publish failure");
14953 assert!(rb.is_err(), "the stacked caller surfaced it too");
14954
14955 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14957 let view = fetch_authority(&bed.relay, &fresh).await;
14958 assert!(view.banned.is_empty(), "no half-published state: {:?}", view.banned);
14959 assert_eq!(fresh.root_epoch, Epoch(0), "no rotation on a failed edition");
14960 }
14961
14962 #[tokio::test]
14963 async fn a_poison_intent_fails_alone_not_its_batchmates() {
14964 let (bed, owner, _member) = TestBed::new();
14967 bed.swap_to(&owner);
14968 let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
14969 let filler: Vec<String> = (0..498).map(|i| format!("{i:064x}")).collect();
14972 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14973 set_banlist(&bed.relay, &held, &filler).await.unwrap();
14974 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14975 crate::db::community::set_community_banlist(&cid_hex, &filler, 1).unwrap();
14976
14977 let (a, x, y) = (Keys::generate().public_key(), Keys::generate().public_key(), Keys::generate().public_key());
14978 let yieldy = YieldyRelay(&bed.relay);
14979 let (wa, wx, wy) = ([a], [x], [y]);
14980 let (ra, rx, ry) = tokio::join!(
14981 set_members_banned(&yieldy, community.id(), &wa, true),
14982 set_members_banned(&yieldy, community.id(), &wx, true),
14983 set_members_banned(&yieldy, community.id(), &wy, true),
14984 );
14985 ra.unwrap();
14986 rx.unwrap();
14987 let err = ry.unwrap_err();
14988 assert!(err.contains("ceiling"), "the over-cap intent failed alone: {err}");
14989
14990 let list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
14991 assert_eq!(list.len(), 500, "the batch published up to the ceiling");
14992 assert!(list.contains(&x.to_hex()) && !list.contains(&y.to_hex()));
14993 }
14994
14995 #[tokio::test]
14996 async fn ban_many_is_one_edition_one_refound_for_the_whole_wave() {
14997 let (bed, owner, _member) = TestBed::new();
14998 bed.swap_to(&owner);
14999 let community = create_community(&bed.relay, "Purge", bed.relays.clone(), None).await.unwrap();
15000 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15001 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
15002 let wave: Vec<PublicKey> = (0..3).map(|_| Keys::generate().public_key()).collect();
15003
15004 set_members_banned(&bed.relay, community.id(), &wave, true).await.unwrap();
15005
15006 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15007 assert_eq!(fresh.root_epoch, Epoch(1), "one refound for the whole wave, not one per target");
15008 let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
15009 assert_eq!(version, 1, "one banlist edition carried the whole wave");
15010 let view = fetch_authority(&bed.relay, &fresh).await;
15011 for pk in &wave {
15012 assert!(view.banned.contains(&pk.to_hex()), "{} missing from the fold", pk.to_hex());
15013 }
15014
15015 set_members_banned(&bed.relay, community.id(), &wave[..2], false).await.unwrap();
15017 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15018 assert_eq!(fresh.root_epoch, Epoch(1), "an unban rotates nothing");
15019 let view = fetch_authority(&bed.relay, &fresh).await;
15020 assert_eq!(view.banned.len(), 1, "reductive: exactly the un-unbanned target remains");
15021 assert!(view.banned.contains(&wave[2].to_hex()));
15022 }
15023
15024 #[tokio::test]
15025 async fn a_batch_past_the_banlist_cap_refuses_before_publishing() {
15026 let (bed, owner, _member) = TestBed::new();
15027 bed.swap_to(&owner);
15028 let community = create_community(&bed.relay, "Cap", bed.relays.clone(), None).await.unwrap();
15029 let wave: Vec<PublicKey> = (0..=crate::community::v2::roles::MAX_BANLIST).map(|_| Keys::generate().public_key()).collect();
15030
15031 let err = set_members_banned(&bed.relay, community.id(), &wave, true).await.unwrap_err();
15032 assert!(err.contains("ceiling"), "refused at the cap: {err}");
15033 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15034 assert_eq!(fresh.root_epoch, Epoch(0), "nothing rotated");
15035 let view = fetch_authority(&bed.relay, &fresh).await;
15036 assert!(view.banned.is_empty(), "nothing published");
15037 }
15038
15039 #[tokio::test]
15040 async fn an_uncited_kick_from_an_admin_is_not_honored() {
15041 let (bed, owner, member) = TestBed::new();
15046 bed.swap_to(&owner);
15047 let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
15048 let admin = Keys::generate();
15049 let member_pk = member.keys.public_key();
15050 grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
15051
15052 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15053 let view = fetch_authority(&bed.relay, &community).await;
15054 crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
15055
15056 let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
15057 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
15058 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
15059 let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
15060
15061 let joined = guestbook::GuestbookEvent {
15062 rumor_id: [1u8; 32],
15063 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
15064 };
15065 let kick = |citation, id: u8, at| guestbook::GuestbookEvent {
15066 rumor_id: [id; 32],
15067 entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
15068 };
15069 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
15070 let empty_bans = std::collections::BTreeSet::new();
15071 let empty_marks = std::collections::BTreeMap::new();
15072 let fold = |evs: &[guestbook::GuestbookEvent]| {
15073 fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
15074 };
15075
15076 assert!(
15077 fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
15078 "an uncited kick from an admin is not honored"
15079 );
15080 assert!(
15081 !fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
15082 "the same kick WITH its synced citation removes them"
15083 );
15084 }
15085
15086 #[tokio::test]
15087 async fn kicking_an_admin_strips_their_roles_first() {
15088 let (bed, owner, member) = TestBed::new();
15092 bed.swap_to(&owner);
15093 let community = create_community(&bed.relay, "Compose", bed.relays.clone(), None).await.unwrap();
15094 let member_pk = member.keys.public_key();
15095 let member_hex = member_pk.to_hex();
15096 let owner_hex = owner.keys.public_key().to_hex();
15097
15098 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
15099 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
15100
15101 kick_member(&bed.relay, &community, &member_pk).await.unwrap();
15102
15103 let view = fetch_authority(&bed.relay, &community).await;
15104 assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
15105 assert!(
15106 !view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
15107 "a kicked admin holds no bit"
15108 );
15109 assert!(
15110 !memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
15111 "and the directive still removed them"
15112 );
15113 }
15114
15115 #[tokio::test]
15116 async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
15117 let (bed, owner, member) = TestBed::new();
15118 bed.swap_to(&owner);
15119 let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
15120 let member_pk = member.keys.public_key();
15121 let member_hex = member_pk.to_hex();
15122 let owner_hex = owner.keys.public_key().to_hex();
15123
15124 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
15125 let view = fetch_authority(&bed.relay, &community).await;
15126 assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
15127 assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
15128
15129 let second = Keys::generate().public_key();
15132 grant_admin(&bed.relay, &community, &second).await.unwrap();
15133 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
15134 let view = fetch_authority(&bed.relay, &community).await;
15135 assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
15136 assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
15137 let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
15138 assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
15139
15140 revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
15142 let view = fetch_authority(&bed.relay, &community).await;
15143 assert!(!view.roles.is_admin(&member_hex), "revoked");
15144 assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
15145 assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
15146 }
15147
15148 #[tokio::test]
15149 async fn follow_control_persists_the_roster_for_sync_local_reads() {
15150 let (bed, owner, member) = TestBed::new();
15151 bed.swap_to(&owner);
15152 let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
15153 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15154 let member_hex = member.keys.public_key().to_hex();
15155 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
15156
15157 follow_control(&bed.relay, &community).await.unwrap();
15159 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
15160 assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
15161
15162 let withholding = MemoryRelay::new();
15165 let _ = follow_control(&withholding, &community).await;
15166 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
15167 assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
15168
15169 revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
15171 follow_control(&bed.relay, &community).await.unwrap();
15172 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
15173 assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
15174 }
15175
15176 #[tokio::test]
15177 async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
15178 let (bed, owner, member) = TestBed::new();
15179 bed.swap_to(&owner);
15180 let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
15181
15182 bed.swap_to(&member);
15183 let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
15184 assert!(err.contains("owner"), "refused before any publish: {err}");
15185
15186 bed.swap_to(&owner);
15189 let view = fetch_authority(&bed.relay, &community).await;
15190 assert!(view.roles.roles.is_empty(), "no role edition landed");
15191 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
15192 let view = fetch_authority(&bed.relay, &community).await;
15193 assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
15194 }
15195
15196 #[tokio::test]
15197 async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
15198 let (bed, owner, member) = TestBed::new();
15199 bed.swap_to(&owner);
15200 let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
15201 let member_pk = member.keys.public_key();
15202
15203 let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
15206 set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
15207 grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
15208
15209 let withholding = MemoryRelay::new();
15212 let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
15213 assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
15214
15215 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
15217 let view = fetch_authority(&bed.relay, &community).await;
15218 let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
15219 assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
15220 assert!(grant.role_ids.contains(&mod_rid));
15221 }
15222
15223 #[tokio::test]
15224 async fn fetch_authority_reflects_a_granted_admin() {
15225 let (bed, owner, member) = TestBed::new();
15226 bed.swap_to(&owner);
15227 let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
15228 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
15229 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
15230 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
15231
15232 let view = fetch_authority(&bed.relay, &community).await;
15233 let member_hex = member.keys.public_key().to_hex();
15234 assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
15235 assert!(
15236 view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
15237 "an ADMIN_ALL grant carries KICK"
15238 );
15239 assert!(view.banned.is_empty());
15240 }
15241
15242 #[tokio::test]
15247 async fn a_pin_edition_folds_persists_and_reads_back() {
15248 let (_tmp, _guard, _owner) = init_test_db();
15249 let relay = MemoryRelay::new();
15250 let community = create_community(&relay, "Pinsville", vec!["wss://r".into()], None).await.unwrap();
15251 let general = community.channels[0].id;
15252 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
15253
15254 send_message(&relay, &community, &general, "pin-worthy").await.unwrap();
15255 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
15256 let opened = page
15257 .iter()
15258 .find_map(|f| match &f.event {
15259 ChatEvent::Message { opened, .. } => Some(opened.clone()),
15260 _ => None,
15261 })
15262 .expect("the sent message reads back");
15263
15264 let ch = community.channels[0].clone();
15265 let conv = channel_conv_key_at(&community, &ch, 0).expect("owner holds the public plane key");
15266 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
15267 let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
15268
15269 let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
15270 publish_control_edition(&relay, &community, vsk::PINS, &eid, &content).await.unwrap();
15271 follow_control(&relay, &community).await.unwrap();
15272
15273 let read = read_channel_pins(&community, &general).unwrap();
15274 assert!(!read.sealed);
15275 assert!(read.version >= 1, "the folded head persisted");
15276 assert_eq!(read.pins.len(), 1);
15277 assert_eq!(read.pins[0].content, "pin-worthy");
15278 assert_eq!(read.pins[0].rumor_id, opened.rumor_id.to_hex());
15279 }
15280
15281 #[tokio::test]
15282 async fn a_fresh_join_sees_pins_and_the_banlist_without_waiting_for_a_follow() {
15283 let (bed, owner, member) = TestBed::new();
15288 bed.swap_to(&owner);
15289 let community = create_community(&bed.relay, "PinJoin", bed.relays.clone(), None).await.unwrap();
15290 let general = community.channels[0].id;
15291 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
15292
15293 send_message(&bed.relay, &community, &general, "pin-worthy").await.unwrap();
15294 let page = fetch_channel(&bed.relay, &community, &general, 10).await.unwrap();
15295 let opened = page
15296 .iter()
15297 .find_map(|f| match &f.event {
15298 ChatEvent::Message { opened, .. } => Some(opened.clone()),
15299 _ => None,
15300 })
15301 .expect("the sent message reads back");
15302 let ch = community.channels[0].clone();
15303 let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
15304 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
15305 let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
15306 let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
15307 publish_control_edition(&bed.relay, &community, vsk::PINS, &eid, &content).await.unwrap();
15308 let spammer = Keys::generate().public_key().to_hex();
15309 set_banlist(&bed.relay, &community, std::slice::from_ref(&spammer)).await.unwrap();
15310 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
15311 let bundle_json = serde_json::to_string(&bundle).unwrap();
15312
15313 bed.swap_to(&member);
15314 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15315 let read = read_channel_pins(&joined, &general).unwrap();
15317 assert!(!read.sealed);
15318 assert_eq!(read.pins.len(), 1, "pins visible the instant the join returns");
15319 assert_eq!(read.pins[0].content, "pin-worthy");
15320 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
15321 let banlist = crate::db::community::get_community_banlist(&cid_hex).unwrap();
15322 assert!(banlist.contains(&spammer), "the banlist is enforced from the join, not the first follow");
15323 }
15324
15325 #[tokio::test]
15328 async fn an_unauthorized_pin_edition_never_folds() {
15329 let (_tmp, _guard, _owner) = init_test_db();
15330 let relay = MemoryRelay::new();
15331 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
15332 let general = community.channels[0].id;
15333
15334 let rogue = Keys::generate();
15335 let group = control::ControlPlane::of(&community).write_group().unwrap();
15336 let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
15337 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::PINS, &eid, 1, None, r#"{"entries":[]}"#, 2_000, None);
15338 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(2_000)).unwrap();
15339 relay.publish(&wrap, &community.relays).await.unwrap();
15340
15341 follow_control(&relay, &community).await.unwrap();
15342 let read = read_channel_pins(&community, &general).unwrap();
15343 assert_eq!(read.version, 0, "an unauthorized edition never persists a head");
15344 assert!(read.pins.is_empty());
15345 }
15346
15347 #[tokio::test]
15350 async fn owner_silently_widens_a_legacy_admin_role() {
15351 let (_tmp, _guard, owner) = init_test_db();
15352 let relay = MemoryRelay::new();
15353 let community = create_community(&relay, "Legacy", vec!["wss://r".into()], None).await.unwrap();
15354 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15355 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5d; 32]);
15356
15357 let legacy = Role {
15359 role_id: rid.clone(),
15360 name: "Admin".into(),
15361 position: 1,
15362 permissions: Permissions(Permissions::ADMIN_FOUNDING_MASK),
15363 scope: RoleScope::Server,
15364 color: 0,
15365 };
15366 publish_role(&relay, &community, &owner, &legacy, 1).await;
15367 follow_control(&relay, &community).await.unwrap();
15368
15369 assert!(upgrade_admin_role_pin_bit(&relay, &community).await.unwrap(), "the widening publishes");
15370 follow_control(&relay, &community).await.unwrap();
15371 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
15372 let widened = roles.roles.iter().find(|r| r.role_id == rid).expect("same entity");
15373 assert!(widened.permissions.contains(Permissions::PIN_MESSAGES), "bit 11 landed");
15374 assert!(widened.permissions.contains(Permissions::ADMIN_FOUNDING_MASK), "nothing stripped");
15375
15376 assert!(!upgrade_admin_role_pin_bit(&relay, &community).await.unwrap());
15378 }
15379
15380 #[tokio::test]
15383 async fn the_deletion_duty_omits_a_pinned_message() {
15384 let (_tmp, _guard, _owner) = init_test_db();
15385 let relay = MemoryRelay::new();
15386 let community = create_community(&relay, "Duties", vec!["wss://r".into()], None).await.unwrap();
15387 let general = community.channels[0].id;
15388 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
15389
15390 let rumor_id = send_message(&relay, &community, &general, "soon deleted").await.unwrap();
15391 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
15392 let opened = page
15393 .iter()
15394 .find_map(|f| match &f.event {
15395 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
15396 _ => None,
15397 })
15398 .unwrap();
15399 let ch = community.channels[0].clone();
15400 let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
15401 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
15402 publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
15403 assert_eq!(read_channel_pins(&community, &general).unwrap().pins.len(), 1);
15404
15405 run_pin_duty(&relay, &ch_hex, &rumor_id, None).await.unwrap();
15407 let after = read_channel_pins(&community, &general).unwrap();
15408 assert!(after.pins.is_empty(), "the omitting edition landed");
15409 assert!(after.version >= 2, "a NEW edition, not a local erase");
15410 }
15411
15412 #[tokio::test]
15415 async fn the_edit_duty_refreshes_a_pinned_proof() {
15416 let (_tmp, _guard, _owner) = init_test_db();
15417 let relay = MemoryRelay::new();
15418 let community = create_community(&relay, "Edits", vec!["wss://r".into()], None).await.unwrap();
15419 let general = community.channels[0].id;
15420 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
15421
15422 let rumor_id = send_message(&relay, &community, &general, "first words").await.unwrap();
15423 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
15424 let opened = page
15425 .iter()
15426 .find_map(|f| match &f.event {
15427 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
15428 _ => None,
15429 })
15430 .unwrap();
15431 let ch = community.channels[0].clone();
15432 let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
15433 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
15434 publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
15435
15436 send_edit(&relay, &community, &general, &rumor_id, "second thoughts").await.unwrap();
15437 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
15438 let edit_opened = page
15439 .iter()
15440 .find_map(|f| match &f.event {
15441 ChatEvent::Edit { opened, .. } => Some(opened.clone()),
15442 _ => None,
15443 })
15444 .expect("the edit reads back");
15445
15446 run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened.clone())).await.unwrap();
15447 let after = read_channel_pins(&community, &general).unwrap();
15448 assert_eq!(after.pins.len(), 1);
15449 assert_eq!(
15450 after.pins[0].content, "second thoughts",
15451 "the refreshed bundle proves the revision"
15452 );
15453 let v = after.version;
15454
15455 run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened)).await.unwrap();
15457 assert_eq!(read_channel_pins(&community, &general).unwrap().version, v, "idempotent");
15458 }
15459
15460 #[tokio::test]
15465 async fn an_own_edit_echo_carries_the_pin_duty_trigger() {
15466 let (_tmp, _guard, _owner) = init_test_db();
15467 let relay = MemoryRelay::new();
15468 let community = create_community(&relay, "OwnEdit", vec!["wss://r".into()], None).await.unwrap();
15469 let general = community.channels[0].id;
15470
15471 let rumor_id = send_message(&relay, &community, &general, "before").await.unwrap();
15472 send_edit(&relay, &community, &general, &rumor_id, "after").await.unwrap();
15473 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
15474 let edit_event = &page.iter().find(|f| matches!(&f.event, ChatEvent::Edit { .. })).expect("the edit reads back").event;
15475 let msg_event = &page.iter().find(|f| matches!(&f.event, ChatEvent::Message { .. })).expect("the message reads back").event;
15476
15477 let updated = crate::community::v2::inbound::ChatPersist::Updated {
15478 message: crate::types::Message::default(),
15479 edit_event: None,
15480 };
15481 let (target_hex, opened) = own_echo_pin_duty(&updated, edit_event).expect("an applied own edit fires the duty");
15482 assert_eq!(target_hex, rumor_id, "the duty targets the EDITED message, not the edit rumor");
15483 assert_ne!(opened.rumor_id.to_hex(), rumor_id, "and carries the edit's own opened stream for the bundle");
15484
15485 let new = crate::community::v2::inbound::ChatPersist::New(crate::types::Message::default());
15487 assert!(own_echo_pin_duty(&new, msg_event).is_none());
15488 assert!(own_echo_pin_duty(&updated, msg_event).is_none());
15490 }
15491
15492 #[tokio::test]
15496 async fn a_rotation_reseals_the_pin_list_under_the_new_epoch() {
15497 let (_tmp, _guard, _owner) = init_test_db();
15498 let relay = MemoryRelay::new();
15499 let community = create_community(&relay, "Reseal", vec!["wss://r".into()], None).await.unwrap();
15500 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15501 let chan = create_private_channel(&relay, &community, "vault").await.unwrap();
15502 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15503 let ch_hex = crate::simd::hex::bytes_to_hex_32(&chan.0);
15504
15505 let rumor_id = send_message(&relay, &community, &chan, "sealed wisdom").await.unwrap();
15506 let page = fetch_channel(&relay, &community, &chan, 10).await.unwrap();
15507 let opened = page
15508 .iter()
15509 .find_map(|f| match &f.event {
15510 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
15511 _ => None,
15512 })
15513 .unwrap();
15514 let ch = community.channel(&chan).unwrap().clone();
15515 let old_epoch = ch.epoch.0;
15516 let conv = channel_conv_key_at(&community, &ch, old_epoch).unwrap();
15517 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
15518 publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
15519 let (_, v_before) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
15520
15521 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
15523 rekey_channel_excluding(&relay, &community, &chan, &roster, &[], &[Keys::generate().public_key()])
15524 .await
15525 .unwrap();
15526
15527 let (content, version) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
15529 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
15530 assert_eq!(
15531 parsed["epoch"].as_str().unwrap(),
15532 (old_epoch + 1).to_string(),
15533 "the reseal names the rotated epoch"
15534 );
15535 assert!(version > v_before, "a real edition, not a local rewrite");
15536
15537 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15539 let read = read_channel_pins(&community, &chan).unwrap();
15540 assert!(!read.sealed);
15541 assert_eq!(read.pins.len(), 1);
15542 assert_eq!(read.pins[0].content, "sealed wisdom");
15543 }
15544
15545 #[tokio::test]
15551 async fn a_published_banlist_echoes_locally_before_any_fold() {
15552 let (_tmp, _guard, _owner) = init_test_db();
15553 let relay = MemoryRelay::new();
15554 let community = create_community(&relay, "Modtown", vec!["wss://r".into()], None).await.unwrap();
15555 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15556 let spammer_a = "aa".repeat(32);
15557 let spammer_b = "bb".repeat(32);
15558
15559 set_banlist(&relay, &community, &[spammer_a.clone()]).await.unwrap();
15561 assert_eq!(
15562 crate::db::community::get_community_banlist(&cid_hex).unwrap(),
15563 vec![spammer_a.clone()],
15564 "the publish echoes without waiting for a fold"
15565 );
15566
15567 let mut list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
15569 list.push(spammer_b.clone());
15570 set_banlist(&relay, &community, &list).await.unwrap();
15571 let held = crate::db::community::get_community_banlist(&cid_hex).unwrap();
15572 assert!(
15573 held.contains(&spammer_a) && held.contains(&spammer_b),
15574 "sequential bans UNION; the second must not erase the first: {held:?}"
15575 );
15576
15577 follow_control(&relay, &community).await.unwrap();
15579 let folded = crate::db::community::get_community_banlist(&cid_hex).unwrap();
15580 assert!(folded.contains(&spammer_a) && folded.contains(&spammer_b));
15581 }
15582}