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 web_time::SystemTime::now()
43 .duration_since(web_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 emoji: &[(&str, &str)],
337) -> Result<String, String> {
338 let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
339 let at_ms = next_send_ms();
340 let rumor = chat::build_edit_rumor(author_pk, channel_id, epoch, target_id_hex, new_content, emoji, at_ms);
341 publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
342}
343
344pub async fn send_delete<T: Transport + ?Sized>(
348 transport: &T,
349 community: &CommunityV2,
350 channel_id: &ChannelId,
351 target_id_hex: &str,
352 target_kind: u16,
353) -> Result<String, String> {
354 let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
355 let at_ms = next_send_ms();
356 let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, None);
357 let id = publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await?;
358 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
363 spawn_pin_duty(&ch_hex, target_id_hex, None);
364 Ok(id)
365}
366
367pub async fn moderation_delete<T: Transport + ?Sized>(
377 transport: &T,
378 community: &CommunityV2,
379 channel_id: &ChannelId,
380 target_id_hex: &str,
381 target_kind: u16,
382 target_author: &PublicKey,
383) -> Result<String, String> {
384 let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
385 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
386 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
387 return Err("this community is dissolved — it accepts no new moderation actions".to_string());
388 }
389 let owner_hex = community.owner()?.to_hex();
390 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
391 if !crate::community::moderation::can_hide(
392 Some(&owner_hex),
393 &roster,
394 &author_pk.to_hex(),
395 &target_author.to_hex(),
396 ) {
397 return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
398 }
399 let at_ms = next_send_ms();
400 let citation = required_authority_citation(community, &author_pk)?;
401 let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, citation.as_ref());
402 let id = publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await?;
403 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
407 spawn_pin_duty(&ch_hex, target_id_hex, None);
408 Ok(id)
409}
410
411pub async fn send_webxdc_signal<T: Transport + ?Sized>(
418 transport: &T,
419 community: &CommunityV2,
420 channel_id: &ChannelId,
421 topic_id: &str,
422 node_addr: Option<&str>,
423) -> Result<(), String> {
424 let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
425 let at_ms = now_ms();
426 let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
427 let rumor = chat::build_webxdc_rumor(author_pk, channel_id, epoch, &content, vec![], at_ms);
428 publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await.map(|_| ())
429}
430
431pub async fn send_typing<T: Transport + ?Sized>(
433 transport: &T,
434 community: &CommunityV2,
435 channel_id: &ChannelId,
436) -> Result<(), String> {
437 let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
438 let at_ms = now_ms();
439 let rumor = chat::build_typing_rumor(author_pk, channel_id, epoch, at_ms);
440 publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
441}
442
443fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(PublicKey, GroupKey, Epoch), String> {
449 let author_pk = me_pk()?;
450 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
451 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
452 return Err("this community has been dissolved".to_string());
453 }
454 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author_pk.to_hex()) {
458 return Err("you are banned from this community".to_string());
459 }
460 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
461 if ch.private && ch.key.is_none() {
462 return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
463 }
464 let (secret, epoch) = community.channel_secret(ch);
465 Ok((author_pk, channel_group_key(&secret, channel_id, epoch), epoch))
466}
467
468#[allow(clippy::too_many_arguments)]
471fn own_echo_pin_duty(
477 outcome: &super::inbound::ChatPersist,
478 event: &ChatEvent,
479) -> Option<(String, super::stream::OpenedStream)> {
480 match (outcome, event) {
481 (super::inbound::ChatPersist::Updated { .. }, ChatEvent::Edit { opened, target, .. }) => {
482 Some((crate::simd::hex::bytes_to_hex_32(target), opened.clone()))
483 }
484 _ => None,
485 }
486}
487
488#[allow(clippy::too_many_arguments)]
489async fn publish_chat<T: Transport + ?Sized>(
490 transport: &T,
491 community: &CommunityV2,
492 group: &GroupKey,
493 author_pk: PublicKey,
494 channel_id: &ChannelId,
495 epoch: Epoch,
496 rumor: nostr_sdk::prelude::UnsignedEvent,
497 at_ms: u64,
498 ephemeral: bool,
499) -> Result<String, String> {
500 crate::db::scoped(async move {
501 let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
502 let signer = crate::signer::active_signer()?;
503 let (wrap, _p_tag_keys) = chat::seal_chat_rumor_signed(&signer, author_pk, &rumor, group, Timestamp::from_secs(at_ms / 1000), ephemeral).await
504 .map_err(|e| e.to_string())?;
505 transport.publish(&wrap, &community.relays).await?;
506 if !ephemeral {
512 crate::db::community::store_message_key(&rumor_id, &wrap.id.to_hex(), group.keys(), &community.relays)?;
513 }
514 if !ephemeral {
519 if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
520 let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
521 let outcome = {
522 let mut st = crate::state::STATE.lock().await;
523 super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author_pk)
524 };
525 if let Some(outcome) = outcome {
526 super::inbound::persist_chat(&channel_hex, &outcome).await;
527 if let Some((target_hex, opened)) = own_echo_pin_duty(&outcome, &event) {
528 spawn_pin_duty(&channel_hex, &target_hex, Some(opened));
529 }
530 }
531 }
532 }
533 Ok(rumor_id)
534 })
535 .await
536}
537
538pub struct FetchedEvent {
541 pub event: ChatEvent,
542 pub epoch: Epoch,
543}
544
545fn heal_own_wrap_key(event: &ChatEvent, group: &GroupKey, relays: &[String]) {
552 if !matches!(event, ChatEvent::Message { .. } | ChatEvent::Reaction { .. }) {
553 return;
554 }
555 let opened = event.opened();
556 if crate::state::my_public_key() != Some(opened.author) {
557 return;
558 }
559 let rumor_hex = opened.rumor_id.to_hex();
560 if !matches!(crate::db::community::get_message_key(&rumor_hex), Ok(None)) {
563 return;
564 }
565 if crate::db::community::store_message_key(&rumor_hex, &opened.wrapper_id.to_hex(), group.keys(), relays).is_ok() {
566 crate::traits::emit_event("message_delete_meta_changed", &serde_json::json!({ "id": rumor_hex }));
569 }
570}
571
572const LIVE_COORD_EPOCHS: usize = 1;
576
577pub async fn fetch_channel<T: Transport + ?Sized>(
581 transport: &T,
582 community: &CommunityV2,
583 channel_id: &ChannelId,
584 limit: usize,
585) -> Result<Vec<FetchedEvent>, String> {
586 fetch_channel_history(transport, community, channel_id, limit, 1, None, None, crate::community::transport::Evidence::Quorum, |_| true).await
587}
588
589#[allow(clippy::too_many_arguments)]
603pub async fn fetch_channel_history<T: Transport + ?Sized>(
604 transport: &T,
605 community: &CommunityV2,
606 channel_id: &ChannelId,
607 page: usize,
608 max_pages: usize,
609 since: Option<u64>,
610 start_until: Option<u64>,
613 evidence: crate::community::transport::Evidence,
614 mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
615) -> Result<Vec<FetchedEvent>, String> {
616 crate::db::scoped(async move {
617 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
620 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
625 let coords: Vec<([u8; 32], Epoch)> = if ch.private {
626 let Some(current) = ch.key else {
627 return Ok(Vec::new());
628 };
629 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
630 let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
631 if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
632 held.push((ch.epoch, current));
633 }
634 held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
637 } else {
638 let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
639 if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
640 roots.push((community.root_epoch, community.community_root));
641 }
642 roots.into_iter().map(|(ep, root)| (root, ep)).collect()
643 };
644 if coords.is_empty() {
645 return Ok(Vec::new());
646 }
647 let mut coords = coords;
654 if start_until.is_none() && coords.len() > LIVE_COORD_EPOCHS {
655 coords.sort_by_key(|(_, e)| std::cmp::Reverse(e.0));
656 coords.truncate(LIVE_COORD_EPOCHS);
657 }
658
659 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
660 let mut seen_rumors = std::collections::HashSet::new();
661 let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
662 let mut until: Option<u64> = start_until;
663 let mut oldest: Option<u64> = None;
664 for _ in 0..max_pages {
665 let mut wraps: Vec<Event> = Vec::new();
671 let mut wrap_ids: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
672 for (secret, epoch) in &coords {
673 let plane = channel_group_key(secret, channel_id, *epoch);
674 let q = Query {
675 kinds: vec![stream::KIND_WRAP],
676 authors: vec![plane.pk_hex()],
677 since,
678 until,
679 limit: Some(page),
680 evidence,
681 ..Default::default()
682 };
683 if let Ok(evs) = transport.fetch_plane(plane.keys(), &q, &community.relays).await {
684 for e in evs.events {
685 if wrap_ids.insert(e.id) {
686 wraps.push(e);
687 }
688 }
689 }
690 }
691 if wraps.is_empty() {
692 break;
693 }
694 let mut fresh = 0usize;
695 let mut page_events: Vec<FetchedEvent> = Vec::new();
696 for wrap in &wraps {
697 if !seen_wraps.insert(wrap.id) {
698 continue;
699 }
700 fresh += 1;
701 let at = wrap.created_at.as_secs();
702 if oldest.is_none_or(|o| at < o) {
703 oldest = Some(at);
704 }
705 for (secret, epoch) in &coords {
707 let group = channel_group_key(secret, channel_id, *epoch);
708 if wrap.pubkey != group.pk() {
709 continue;
710 }
711 if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
712 let id = event.opened().rumor_id;
713 if seen_rumors.insert(id) {
714 heal_own_wrap_key(&event, &group, &community.relays);
715 page_events.push(FetchedEvent { event, epoch: *epoch });
716 }
717 }
718 break;
719 }
720 }
721 if fresh == 0 {
722 if wraps.len() < page {
723 break; }
725 let Some(o) = oldest else { break };
729 if o == 0 {
730 break;
731 }
732 crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
733 until = Some(o - 1);
734 continue;
735 }
736 let stop = !page_events.is_empty() && !keep_paging(&page_events);
737 out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
738 if stop {
739 break; }
741 until = oldest; }
743 out.sort_by(|a, b| {
748 a.0.cmp(&b.0)
749 .then_with(|| a.1.event.opened().rumor_id.as_bytes().cmp(b.1.event.opened().rumor_id.as_bytes()))
750 });
751 Ok(out.into_iter().map(|(_, e)| e).collect())
752 })
753 .await
754}
755
756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
765pub enum BundleAudience {
766 Link,
768 Member(PublicKey),
770}
771
772pub fn bundle_of(
778 community: &CommunityV2,
779 audience: BundleAudience,
780 creator: Option<PublicKey>,
781 expires_at_ms: Option<u64>,
782 label: Option<String>,
783) -> CommunityInvite {
784 bundle_of_with_overlay(community, audience, creator, expires_at_ms, label, &[], &[])
785}
786
787pub fn bundle_of_with_overlay(
791 community: &CommunityV2,
792 audience: BundleAudience,
793 creator: Option<PublicKey>,
794 expires_at_ms: Option<u64>,
795 label: Option<String>,
796 with: &[String],
797 without: &[String],
798) -> CommunityInvite {
799 let hex = crate::simd::hex::bytes_to_hex_32;
800 let cid_hex = hex(&community.identity.community_id.0);
801 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
802 let owner_hex = community.owner().ok().map(|o| o.to_hex());
803 let recipient_hex = match audience {
804 BundleAudience::Link => None,
805 BundleAudience::Member(pk) => Some(pk.to_hex()),
806 };
807 let channels = community
808 .vendable_channels(&roster, owner_hex.as_deref(), recipient_hex.as_deref(), with, without)
809 .into_iter()
810 .map(|c| invite::ChannelGrant {
811 id: hex(&c.id.0),
812 key: hex(&c.key.unwrap_or(community.community_root)),
813 epoch: c.epoch.0,
814 name: c.name.clone(),
815 })
816 .collect();
817 CommunityInvite {
818 community_id: hex(&community.identity.community_id.0),
819 owner: hex(&community.identity.owner_xonly),
820 owner_salt: hex(&community.identity.owner_salt),
821 community_root: hex(&community.community_root),
822 root_epoch: community.root_epoch.0,
823 control_pk: community.control_pk.map(|p| p.to_hex()),
826 channels,
827 relays: community.relays.clone(),
828 name: community.name.clone(),
829 icon: community.icon.clone(),
832 expires_at: expires_at_ms,
833 creator_npub: creator.map(|p| p.to_hex()),
834 label,
835 extra: Default::default(),
836 }
837}
838
839pub async fn send_direct_invite<T: Transport + ?Sized>(
845 transport: &T,
846 community: &CommunityV2,
847 recipient: &PublicKey,
848 expires_at_ms: Option<u64>,
849 label: Option<String>,
850) -> Result<Event, String> {
851 crate::db::scoped(async move {
852 assert_current_root(community)?;
855 let signer = crate::signer::active_signer()?;
856 let inviter_pk = me_pk()?;
857 let bundle = bundle_of(community, BundleAudience::Member(*recipient), Some(inviter_pk), expires_at_ms, label);
858 let wrap = invite::build_direct_invite_signed(&signer, inviter_pk, recipient, &bundle).await.map_err(|e| e.to_string())?;
859 transport.publish(&wrap, &community.relays).await?;
860 Ok(wrap)
861 })
862 .await
863}
864
865pub struct MintedLink {
869 pub url: String,
870 pub bundle_event: Event,
871 pub link_signer: Keys,
872 pub token: [u8; super::derive::TOKEN_LEN],
873 pub expires_at_ms: Option<u64>,
878 pub label: Option<String>,
879}
880
881pub async fn mint_public_link<T: Transport + ?Sized>(
887 transport: &T,
888 community: &CommunityV2,
889 base: &str,
890 expires_at_ms: Option<u64>,
891 label: Option<String>,
892) -> Result<MintedLink, String> {
893 crate::db::scoped(async move {
894 ensure_folded_permission(community, &me_pk()?, crate::community::roles::Permissions::CREATE_INVITE, "minting a public invite link")?;
897 let mut token = [0u8; super::derive::TOKEN_LEN];
898 token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
899 let link_signer = Keys::generate();
900 let bundle = bundle_of(community, BundleAudience::Link, Some(me_pk()?), expires_at_ms, label.clone());
901 let bundle_key = super::derive::invite_bundle_key(&token);
902 let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
903 let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
904
905 transport.publish_durable(&bundle_event, &community.relays).await?;
906 let minted = MintedLink { url, bundle_event, link_signer, token, expires_at_ms, label: label.clone() };
907 let _ = record_minted_link(transport, community, &minted).await;
911 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
915 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
916 let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
917 Ok(minted)
918 })
919 .await
920}
921
922async fn fetch_invite_list<T: Transport + ?Sized>(
931 transport: &T,
932 relays: &[String],
933) -> Result<Option<invite::InviteList>, String> {
934 let signer = crate::signer::active_signer()?;
935 let my_pk = me_pk()?;
936 let query = Query {
937 kinds: vec![super::kind::INVITE_LIST],
938 authors: vec![my_pk.to_hex()],
939 limit: Some(4),
940 evidence: crate::community::transport::Evidence::Full,
941 ..Default::default()
942 };
943 let events = transport.fetch(&query, relays).await?;
944 let mut best: Option<(u64, invite::InviteList)> = None;
945 for e in events {
946 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
947 let at = e.created_at.as_secs();
948 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
949 best = Some((at, l));
950 }
951 }
952 }
953 Ok(best.map(|(_, l)| l))
954}
955
956fn live_signers_for(list: &invite::InviteList, community_id_hex: &str, now_ms: u64) -> Vec<PublicKey> {
964 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
965 list.entries
966 .iter()
967 .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
968 .filter(|e| e.expires_at.is_none_or(|exp| now_ms <= exp))
969 .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
970 .collect()
971}
972
973async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, __session: &std::sync::Arc<crate::db::Session>, live_signers: &[PublicKey]) -> Result<(), String> {
977 let my_pk = me_pk()?;
978 let eid = super::derive::invite_links_locator(community.id(), &my_pk.to_bytes());
979 let content = invite::build_registry_content(live_signers);
980 publish_control_edition(transport, community, vsk::INVITE_LINKS, &eid, &content).await?;
981 refresh_invite_registry_cache(transport, community).await;
985 Ok(())
986}
987
988async fn refresh_invite_registry_cache<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) {
992 crate::db::scoped(async move {
993 let Ok(owner) = community.owner() else { return };
994 let Some(editions) = fetch_control_plane_whole(transport, community).await else { return };
995 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
996 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
997 .unwrap_or_default()
998 .into_iter()
999 .filter(|(_, f)| f.0 == community.root_epoch.0)
1000 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1001 .collect();
1002 let authority = fold_authority(community, &editions, &floors);
1003 let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
1004 let _ = crate::db::community::set_community_invite_registry(&cid_hex, &flatten_link_sets(&sets));
1005 let _ = crate::db::community::replace_invite_link_sets(&cid_hex, &sets);
1006 })
1007 .await
1008}
1009
1010async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
1013 crate::db::scoped(async move {
1014 let session = crate::db::current_session();
1015 let signer = crate::signer::active_signer()?;
1016 let my_pk = me_pk()?;
1017 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1018 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
1019 let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
1024 if !list.entries.iter().any(|e| e.token == token_hex) {
1025 list.entries.push(invite::InviteEntry {
1026 token: token_hex,
1027 signer_sk: minted.link_signer.secret_key().to_secret_hex(),
1028 community_id: cid_hex.clone(),
1029 url: minted.url.clone(),
1030 label: minted.label.clone(),
1031 created_at: now_ms() / 1000,
1032 expires_at: minted.expires_at_ms,
1033 epoch: Some(community.root_epoch.0),
1034 extra: Default::default(),
1035 });
1036 }
1037 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1038 transport.publish(&event, &community.relays).await?;
1039 let signers = live_signers_for(&list, &cid_hex, now_ms());
1040 publish_invite_registry(transport, community, &session, &signers).await
1041 })
1042 .await
1043}
1044
1045pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
1051 crate::db::scoped(async move {
1052 let session = crate::db::current_session();
1053 let signer = crate::signer::active_signer()?;
1054 let my_pk = me_pk()?;
1055 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1056 let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
1057 let entry = list.entries.iter().find(|e| e.token == token_hex && e.community_id == cid_hex).cloned();
1058 let Some(entry) = entry else {
1059 let tombstoned = list.tombstones.iter().any(|t| t.token == token_hex && t.community_id == cid_hex);
1066 crate::log_warn!(
1067 "[Invites] link {} is not in the fetched invite list ({}) — pruning the local record",
1068 &token_hex[..token_hex.len().min(8)],
1069 if tombstoned { "already tombstoned" } else { "retired elsewhere" }
1070 );
1071 let _ = crate::db::community::delete_public_invite(token_hex);
1072 return Ok(());
1073 };
1074 let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
1076 let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
1077 transport.publish_durable(&revocation, &community.relays).await?;
1078 list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
1080 list.entries.retain(|e| e.token != token_hex);
1081 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1082 transport.publish(&event, &community.relays).await?;
1083 let signers = live_signers_for(&list, &cid_hex, now_ms());
1084 let others_vend = if signers.is_empty() {
1092 match fetch_control_plane_whole(transport, community).await {
1093 Some(editions) => {
1094 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1095 .unwrap_or_default()
1096 .into_iter()
1097 .filter(|(_, f)| f.0 == community.root_epoch.0)
1098 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1099 .collect();
1100 let owner_hex = community.owner().map(|o| o.to_hex()).unwrap_or_default();
1101 let authority = fold_authority(community, &editions, &floors);
1102 live_invite_link_sets(community.id(), &owner_hex, &editions, &authority, &floors)
1103 .iter()
1104 .any(|s| s.creator_hex != my_pk.to_hex())
1105 }
1106 None => true,
1111 }
1112 } else {
1113 false
1114 };
1115 publish_invite_registry(transport, community, &session, &signers).await?;
1116 let _ = crate::db::community::delete_public_invite(token_hex);
1117 if signers.is_empty() && !others_vend {
1131 let mut rotated = false;
1132 for attempt in 0..3u8 {
1133 match refound_community(transport, community, &[]).await {
1134 Ok(_) => {
1135 rotated = true;
1136 break;
1137 }
1138 Err(e) if attempt == 2 => {
1139 crate::log_warn!(
1140 "[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",
1141 &cid_hex[..8.min(cid_hex.len())]
1142 );
1143 crate::emit_event(
1144 "community_privatize_rotation_failed",
1145 &serde_json::json!({ "community_id": cid_hex, "error": e }),
1146 );
1147 }
1148 Err(_) => continue,
1149 }
1150 }
1151 if rotated {
1152 crate::log_info!("[v2] {} privatized: base key rotated so link-joined readers are cut off", &cid_hex[..8.min(cid_hex.len())]);
1153 }
1154 }
1155 Ok(())
1156 })
1157 .await
1158}
1159
1160pub(crate) fn sever_marks(cid_hex: &str) -> std::collections::BTreeMap<u64, u64> {
1166 crate::db::settings::get_sql_setting(format!("v2_sever:{cid_hex}"))
1167 .ok()
1168 .flatten()
1169 .and_then(|v| serde_json::from_str(&v).ok())
1170 .unwrap_or_default()
1171}
1172
1173pub(crate) fn epoch_minters(cid_hex: &str) -> std::collections::BTreeMap<u64, String> {
1178 crate::db::settings::get_sql_setting(format!("v2_minter:{cid_hex}"))
1179 .ok()
1180 .flatten()
1181 .and_then(|v| serde_json::from_str(&v).ok())
1182 .unwrap_or_default()
1183}
1184
1185pub(crate) fn record_epoch_minter(cid_hex: &str, epoch: u64, rotator_hex: &str) {
1189 let mut m = epoch_minters(cid_hex);
1190 if m.get(&epoch).is_some_and(|held| held == rotator_hex) {
1191 return;
1192 }
1193 m.insert(epoch, rotator_hex.to_string());
1194 if let Ok(v) = serde_json::to_string(&m) {
1195 let _ = crate::db::settings::set_sql_setting(format!("v2_minter:{cid_hex}"), v);
1196 }
1197}
1198
1199pub(crate) fn add_sever_mark(cid_hex: &str, epoch: u64, at_secs: u64) {
1202 let mut marks = sever_marks(cid_hex);
1203 marks.insert(epoch, at_secs);
1204 if let Ok(v) = serde_json::to_string(&marks) {
1205 let _ = crate::db::settings::set_sql_setting(format!("v2_sever:{cid_hex}"), v);
1206 }
1207}
1208
1209async fn sever_own_links<T: Transport + ?Sized>(
1222 transport: &T,
1223 community: &CommunityV2,
1224) -> Result<usize, String> {
1225 crate::db::scoped(async move {
1226 let session = crate::db::current_session();
1227 let signer = crate::signer::active_signer()?;
1228 let my_pk = me_pk()?;
1229 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1230 let Some(condemn_below) = sever_marks(&cid_hex).keys().copied().max() else {
1231 return Ok(0); };
1233 let maybe_mine = crate::db::community::invite_link_set_creators(&cid_hex)
1237 .map(|cs| cs.contains(&my_pk.to_hex()))
1238 .unwrap_or(true)
1239 || !crate::db::community::list_public_invites(&cid_hex).unwrap_or_default().is_empty();
1240 if !maybe_mine {
1241 return Ok(0);
1242 }
1243 let Some(mut list) = fetch_invite_list(transport, &community.relays).await? else {
1244 return Ok(0); };
1246 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1247 let victims: Vec<invite::InviteEntry> = list
1248 .entries
1249 .iter()
1250 .filter(|e| e.community_id == cid_hex && !dead.contains(e.token.as_str()) && e.epoch.unwrap_or(0) < condemn_below)
1251 .cloned()
1252 .collect();
1253 if victims.is_empty() {
1254 return Ok(0);
1255 }
1256 let mut revoked_tokens: Vec<String> = Vec::with_capacity(victims.len());
1257 for entry in &victims {
1258 match Keys::parse(&entry.signer_sk) {
1262 Ok(link_signer) => {
1263 let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
1264 transport.publish_durable(&revocation, &community.relays).await?;
1265 }
1266 Err(_) => crate::log_warn!("[Sever] link {} carries a malformed signer — tombstoning the husk", &entry.token[..entry.token.len().min(8)]),
1267 }
1268 list.tombstones.push(invite::InviteTombstone {
1269 token: entry.token.clone(),
1270 community_id: cid_hex.clone(),
1271 extra: Default::default(),
1272 });
1273 revoked_tokens.push(entry.token.clone());
1274 }
1275 list.entries.retain(|e| !revoked_tokens.contains(&e.token));
1276 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1277 transport.publish(&event, &community.relays).await?;
1278 let signers = live_signers_for(&list, &cid_hex, now_ms());
1279 publish_invite_registry(transport, community, &session, &signers).await?;
1280 for token in &revoked_tokens {
1281 let _ = crate::db::community::delete_public_invite(token);
1282 }
1283 crate::log_info!("[Sever] revoked {} own public link(s) (containment)", revoked_tokens.len());
1284 Ok(revoked_tokens.len())
1285 })
1286 .await
1287}
1288
1289pub(crate) async fn retry_pending_severance<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) {
1294 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1295 if sever_marks(&cid_hex).is_empty() {
1296 return;
1297 }
1298 if let Err(e) = sever_own_links(transport, community).await {
1299 crate::log_warn!("[Sever] pending link severance retry failed (will retry next follow): {e}");
1300 }
1301}
1302
1303pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1308 crate::db::scoped(async move {
1309 let session = crate::db::current_session();
1310 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1311 let signer = crate::signer::active_signer()?;
1316 let my_pk = me_pk()?;
1317 let query = Query {
1318 kinds: vec![super::kind::INVITE_LIST],
1319 authors: vec![my_pk.to_hex()],
1320 limit: Some(4),
1321 ..Default::default()
1322 };
1323 let events = transport.fetch(&query, &community.relays).await?;
1324 let mut best: Option<(u64, invite::InviteList)> = None;
1325 for e in events {
1326 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
1327 let at = e.created_at.as_secs();
1328 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
1329 best = Some((at, l));
1330 }
1331 }
1332 }
1333 let Some((_, list)) = best else {
1334 return Ok(());
1335 };
1336 let creator = my_pk;
1337 let now = now_ms();
1338 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1339 let condemn_below = sever_marks(&cid_hex).keys().copied().max().unwrap_or(0);
1344 for entry in &list.entries {
1345 if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
1346 continue;
1347 }
1348 if entry.epoch.unwrap_or(0) < condemn_below {
1349 continue;
1350 }
1351 if entry.expires_at.is_some_and(|exp| now > exp) {
1354 continue;
1355 }
1356 let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
1357 let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
1358 let bundle = bundle_of(community, BundleAudience::Link, Some(creator), entry.expires_at, entry.label.clone());
1359 let bundle_key = super::derive::invite_bundle_key(&token);
1360 if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
1361 let _ = transport.publish_durable(&event, &community.relays).await;
1362 }
1363 }
1364 let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
1374 if !mine_here {
1375 return Ok(());
1376 }
1377 let signers = live_signers_for(&list, &cid_hex, now);
1378 let _ = publish_invite_registry(transport, community, &session, &signers).await;
1379 Ok(())
1380 })
1381 .await
1382}
1383
1384pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1389 let Ok(owner) = community.owner() else { return false };
1390 let Some(editions) = fetch_control_plane_whole(transport, community).await else { return true };
1394 let cid = community.id();
1395 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1396 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1397 .unwrap_or_default()
1398 .into_iter()
1399 .filter(|(_, f)| f.0 == community.root_epoch.0)
1400 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1401 .collect();
1402 let authority = fold_authority(community, &editions, &floors);
1403 !live_invite_link_sets(cid, &owner.to_hex(), &editions, &authority, &floors).is_empty()
1404}
1405
1406async fn fetch_control_plane_whole<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Option<Vec<ParsedEdition>> {
1412 let control = control::ControlPlane::of(community);
1413 let mut editions: Vec<ParsedEdition> = Vec::new();
1414 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1415 let mut oldest: Option<u64> = None;
1416 let mut until: Option<u64> = None;
1417 for page in 0..COMPACT_MAX_PAGES {
1418 let query = Query {
1425 kinds: vec![stream::KIND_WRAP],
1426 authors: vec![control.pk_hex()],
1427 until,
1428 limit: Some(FOLLOW_PAGE),
1429 evidence: crate::community::transport::Evidence::Full,
1430 ..Default::default()
1431 };
1432 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return None };
1433 let mut fresh = 0usize;
1434 for w in &wraps {
1435 if !seen_wraps.insert(w.id) {
1436 continue;
1437 }
1438 fresh += 1;
1439 let at = w.created_at.as_secs();
1440 if oldest.is_none_or(|o| at < o) {
1441 oldest = Some(at);
1442 }
1443 if let Ok((ed, _)) = control.open(w) {
1444 editions.push(ed);
1445 }
1446 }
1447 if fresh == 0 {
1448 if wraps.len() >= FOLLOW_PAGE {
1449 return None; }
1451 return Some(editions);
1452 }
1453 until = oldest;
1454 if page + 1 == COMPACT_MAX_PAGES {
1455 return None;
1456 }
1457 }
1458 Some(editions)
1459}
1460
1461fn live_invite_link_sets(
1466 cid: &crate::community::CommunityId,
1467 owner_hex: &str,
1468 editions: &[ParsedEdition],
1469 authority: &AuthoritySet,
1470 floors: &Floors,
1471) -> Vec<crate::db::community::InviteLinkSetRow> {
1472 use crate::community::roles::Permissions;
1473 use std::collections::BTreeMap;
1474 let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
1475 for e in editions {
1476 if e.vsk == vsk::INVITE_LINKS {
1477 by_eid.entry(e.entity_id).or_default().push(e);
1478 }
1479 }
1480 let mut sets: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1481 for (eid, group) in &by_eid {
1482 let authed: Vec<&ParsedEdition> = group
1488 .iter()
1489 .copied()
1490 .filter(|p| {
1491 let author = p.author.to_hex();
1492 !authority.banned.contains(&author)
1494 && authority.roles.is_authorized(&author, Some(owner_hex), Permissions::CREATE_INVITE)
1495 && super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
1496 })
1497 .collect();
1498 if authed.is_empty() {
1499 continue;
1500 }
1501 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
1502 let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
1503 if let Ok(signers) = invite::parse_registry_content(&authed[hi].content) {
1504 if signers.is_empty() {
1505 continue; }
1507 sets.push(crate::db::community::InviteLinkSetRow {
1508 creator_hex: authed[hi].author.to_hex(),
1509 locators: signers.iter().map(|p| p.to_hex()).collect(),
1510 });
1511 }
1512 }
1513 sets
1514}
1515
1516fn flatten_link_sets(sets: &[crate::db::community::InviteLinkSetRow]) -> Vec<String> {
1518 let mut flat: Vec<String> = sets.iter().flat_map(|s| s.locators.iter().cloned()).collect();
1519 flat.sort();
1520 flat.dedup();
1521 flat
1522}
1523
1524async fn accept_bundle<T: Transport + ?Sized>(
1530 transport: &T,
1531 bundle: &CommunityInvite,
1532 invited_by: Option<PublicKey>,
1533 announce_join: bool,
1534) -> Result<CommunityV2, String> {
1535 crate::db::scoped(async move {
1536 let signer = crate::signer::active_signer()?;
1537 let my_pk = me_pk()?;
1538 let at_ms = now_ms();
1539 if bundle.expired(at_ms) {
1541 return Err("this invite has expired".to_string());
1542 }
1543 let community = CommunityV2::from_bundle(bundle, at_ms)?;
1545 let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
1548
1549 let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
1558 v.session.is_live()
1559 && v.at.elapsed() < VERIFIED_PREVIEW_TTL
1560 && v.community_id == community.id().0
1561 && v.community_root == community.community_root
1562 });
1563 let (community, join_heads, join_banlist, join_pins, join_banlist_content, join_roles, join_authority_heads) =
1564 match handoff {
1565 Some(v) => {
1566 let mut c = v.folded;
1567 c.created_at_ms = at_ms;
1569 (c, v.heads, v.banned, v.pins, v.banlist_persist, v.roles, v.authority_heads)
1570 }
1571 None => {
1572 let vj = verify_owner_root_and_reconcile(transport, community).await?;
1573 (vj.community, vj.heads, vj.banned, vj.pins, vj.banlist_persist, vj.roles, vj.authority_heads)
1574 }
1575 };
1576
1577 if is_dissolved(transport, &community).await {
1579 return Err(ERR_DISSOLVED.to_string());
1580 }
1581
1582 if join_banlist.contains(&my_pk.to_hex()) {
1587 return Err("you are banned from this community".to_string());
1588 }
1589
1590 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1598 for h in join_heads.iter().chain(join_authority_heads.iter()) {
1599 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)?;
1600 }
1601 crate::db::community::save_community_v2(&community)?;
1602 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
1605 for ch in &community.channels {
1608 if let (true, Some(key)) = (ch.private, ch.key) {
1609 let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
1610 }
1611 }
1612 if let Some((banned_list, version)) = &join_banlist_content {
1618 let _ = crate::db::community::set_community_banlist(&cid_hex, banned_list, *version as i64);
1619 }
1620 if let Some((roles, at)) = &join_roles {
1626 let _ = crate::db::community::set_community_roles(&cid_hex, roles, *at);
1627 }
1628 for (channel_hex, content, version, _author, _at) in &join_pins {
1629 if matches!(
1630 crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64),
1631 Ok(true)
1632 ) {
1633 crate::emit_event(
1634 "community_pins_updated",
1635 &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
1636 );
1637 }
1638 }
1639
1640 if announce_join && !already_held {
1646 let attribution = invited_by
1647 .map(|p| p.to_hex())
1648 .or_else(|| bundle.creator_npub.clone())
1649 .zip(Some(bundle.label.clone().unwrap_or_default()));
1650 let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
1651 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1652 let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
1653 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1654 let _ = transport.publish(&join_wrap, &community.relays).await;
1655 }
1656 }
1657
1658 if announce_join {
1668 match republish_community_list(transport, Some(community.id())).await {
1669 Ok(true) => {}
1670 Ok(false) => republish_community_list_durable(Some(*community.id())),
1671 Err(e) => {
1672 crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
1673 republish_community_list_durable(Some(*community.id()));
1674 }
1675 }
1676 }
1677 Ok(community)
1678 })
1679 .await
1680}
1681
1682struct VerifiedJoin {
1704 community: CommunityV2,
1705 heads: Vec<FoldedHead>,
1706 banned: std::collections::BTreeSet<String>,
1708 pins: Vec<(String, String, u64, String, u64)>,
1711 banlist_persist: Option<(Vec<String>, u64)>,
1713 roles: Option<(crate::community::roles::CommunityRoles, i64)>,
1721 authority_heads: Vec<FoldedHead>,
1725}
1726
1727async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
1728 transport: &T,
1729 community: CommunityV2,
1730) -> Result<VerifiedJoin, String> {
1731 let owner = community.owner()?;
1732 let control = control::ControlPlane::of(&community);
1733 let control_pk = control.pk_hex();
1734
1735 super::streamauth::prime(&community);
1742
1743 const PAGE: usize = 500;
1764 const MAX_PAGES: usize = 4;
1765 const FAR_FUTURE_SECS: u64 = 4_102_444_800; let mut editions: Vec<ParsedEdition> = Vec::new();
1767 let mut all_editions: Vec<ParsedEdition> = Vec::new();
1768 let mut found_genesis = false;
1769 let mut compacted_metadata = false;
1777 crate::log_debug!(
1778 "[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
1779 &control_pk[..12], community.root_epoch, community.relays
1780 );
1781 let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
1782 found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
1783 };
1784 for attempt in 0..2 {
1785 editions.clear();
1786 all_editions.clear();
1787 compacted_metadata = false;
1788 let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1789 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1790 for page_no in 0..MAX_PAGES {
1791 let query = Query {
1792 kinds: vec![stream::KIND_WRAP],
1793 authors: vec![control_pk.clone()],
1794 until,
1795 limit: Some(PAGE),
1796 evidence: crate::community::transport::Evidence::Full,
1797 ..Default::default()
1798 };
1799 let wraps = transport.fetch(&query, &community.relays).await?;
1800 crate::log_trace!(
1801 "[JoinVerify] attempt {} page {}: fetched {} wraps",
1802 attempt, page_no, wraps.len()
1803 );
1804 let mut oldest = u64::MAX;
1808 let mut fresh = 0usize;
1809 for w in &wraps {
1810 if !seen_wraps.insert(w.id) {
1811 continue;
1812 }
1813 fresh += 1;
1814 oldest = oldest.min(w.created_at.as_secs());
1815 if let Ok((ed, _)) = control.open(w) {
1816 crate::log_trace!(
1817 "[JoinVerify] edition vsk={} eid={} owner={} at={}",
1818 ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
1819 ed.author == owner, w.created_at.as_secs()
1820 );
1821 if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1822 if ed.author == owner {
1823 found_genesis = true;
1824 } else {
1825 compacted_metadata = true;
1826 }
1827 }
1828 if ed.author == owner {
1829 editions.push(ed.clone());
1830 }
1831 all_editions.push(ed);
1834 }
1835 }
1836 crate::log_debug!(
1837 "[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
1838 attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
1839 );
1840 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
1841 break; }
1843 until = Some(oldest);
1844 }
1845 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1846 break;
1847 }
1848 if attempt == 0 {
1849 if let Some(client) = crate::state::nostr_client() {
1856 super::streamauth::prime_auth(&client, &community.relays).await;
1857 }
1858 }
1859 }
1860 if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1861 return Err(
1862 "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"
1863 .to_string(),
1864 );
1865 }
1866 let empty_floors = Floors::new();
1875 let authority = AuthoritySet::owner_only();
1876 let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1877 let authority_full = fold_authority(&community, &all_editions, &empty_floors);
1882 let pins = apply_control_fold(&community, &all_editions, &empty_floors, &authority_full).pins_persist;
1887 let newest_roster_at = all_editions
1890 .iter()
1891 .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
1892 .map(|e| e.created_at as i64)
1893 .max()
1894 .unwrap_or(0);
1895 let roles = (!authority_full.gapped).then(|| (authority_full.roles.clone(), newest_roster_at));
1899 Ok(VerifiedJoin {
1900 community: fold.updated.unwrap_or(community),
1901 heads: fold.heads,
1902 banned: authority_full.banned,
1903 pins,
1904 banlist_persist: authority_full.banlist_persist,
1905 roles,
1906 authority_heads: authority_full.heads,
1907 })
1908}
1909
1910pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1914 let signer = crate::signer::active_signer()?;
1915 let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
1916 accept_bundle(transport, &bundle, Some(inviter), true).await
1917}
1918
1919pub async fn accept_parked_invite<T: Transport + ?Sized>(
1925 transport: &T,
1926 bundle_json: &str,
1927 inviter_hex: Option<&str>,
1928) -> Result<CommunityV2, String> {
1929 let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1930 let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1931 accept_bundle(transport, &bundle, invited_by, true).await
1932}
1933
1934pub async fn accept_migration_material<T: Transport + ?Sized>(
1940 transport: &T,
1941 jm: &super::list::JoinMaterial,
1942) -> Result<CommunityV2, String> {
1943 let bundle = material_to_invite(jm);
1944 accept_bundle(transport, &bundle, None, true).await
1945}
1946
1947pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1954 let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1955 let query = Query {
1960 kinds: vec![super::kind::INVITE_BUNDLE],
1961 authors: vec![parsed.link_signer.to_hex()],
1962 ..Default::default()
1963 };
1964 let relays = if parsed.bootstrap_relays.is_empty() {
1965 invite::stock_relays()
1966 } else {
1967 parsed.bootstrap_relays.clone()
1968 };
1969 let events = match transport.fetch(&query, &relays).await {
1974 Ok(evs) => evs,
1975 Err(_) => {
1976 wait_for_bootstrap_relay(&relays).await;
1977 transport.fetch(&query, &relays).await?
1978 }
1979 };
1980 let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1981
1982 let mut newest_live: Option<(u64, CommunityInvite)> = None;
1984 for event in &events {
1985 match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1986 Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1987 Ok(invite::BundleState::Live(bundle)) => {
1988 let at = event.created_at.as_secs();
1989 if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1990 newest_live = Some((at, *bundle));
1991 }
1992 }
1993 Err(_) => {} }
1995 }
1996 newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1997}
1998
1999async fn wait_for_bootstrap_relay(relays: &[String]) {
2003 let Some(client) = crate::state::nostr_client() else { return };
2004 let deadline = crate::rt::time::Instant::now() + std::time::Duration::from_secs(8);
2005 loop {
2006 for url in relays {
2007 if let Ok(Some(relay)) = client.relay(url).await {
2008 if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
2009 return;
2010 }
2011 }
2012 }
2013 if crate::rt::time::Instant::now() >= deadline {
2014 return;
2015 }
2016 crate::rt::time::sleep(std::time::Duration::from_millis(400)).await;
2017 }
2018}
2019
2020struct VerifiedPreview {
2026 session: std::sync::Arc<crate::db::Session>,
2027 at: web_time::Instant,
2028 community_id: [u8; 32],
2029 community_root: [u8; 32],
2030 folded: CommunityV2,
2031 heads: Vec<FoldedHead>,
2032 banned: std::collections::BTreeSet<String>,
2035 pins: Vec<(String, String, u64, String, u64)>,
2038 banlist_persist: Option<(Vec<String>, u64)>,
2039 roles: Option<(crate::community::roles::CommunityRoles, i64)>,
2040 authority_heads: Vec<FoldedHead>,
2041}
2042static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
2043const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
2044
2045pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
2052 let bundle = fetch_public_bundle(transport, url).await?;
2053 preview_bundle(transport, &bundle).await
2054}
2055
2056pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
2061 let community = CommunityV2::from_bundle(bundle, 0)?;
2062 match verify_owner_root_and_reconcile(transport, community.clone()).await {
2063 Ok(vj) => {
2064 let folded = vj.community;
2065 *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
2066 session: crate::db::current_session(),
2067 at: web_time::Instant::now(),
2068 community_id: folded.id().0,
2069 community_root: folded.community_root,
2070 folded: folded.clone(),
2071 heads: vj.heads,
2072 banned: vj.banned,
2073 pins: vj.pins,
2074 banlist_persist: vj.banlist_persist,
2075 roles: vj.roles,
2076 authority_heads: vj.authority_heads,
2077 });
2078 Ok(folded)
2079 }
2080 Err(_) => Ok(community),
2081 }
2082}
2083
2084pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
2086 crate::db::scoped(async move {
2087 let bundle = fetch_public_bundle(transport, url).await?;
2089 accept_bundle(transport, &bundle, None, true).await
2090 })
2091 .await
2092}
2093
2094pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2096 crate::db::scoped(async move {
2097 let signer = crate::signer::active_signer()?;
2098 let my_pk = me_pk()?;
2099 let at_ms = now_ms();
2100 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2101 let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
2102 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
2103 let _ = transport.publish(&wrap, &community.relays).await;
2104 }
2105 if let Err(e) = tombstone_community_list(transport, community.id(), &community.relays, at_ms).await {
2109 crate::log_net_fail!("[CommunityList] leave tombstone failed ({}) — retrying in the background", e);
2110 tombstone_community_list_durable(*community.id(), community.relays.clone(), at_ms);
2111 }
2112 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2113 Ok(())
2114 })
2115 .await
2116}
2117
2118pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
2123 crate::db::scoped(async move {
2124 assert_current_root(community)?;
2125 let signer = crate::signer::active_signer()?;
2126 let my_pk = me_pk()?;
2127 let authority = fetch_authority(transport, community).await;
2129 let owner_hex = community.owner()?.to_hex();
2130 if !authority.roles.can_act_on_member(
2131 &my_pk.to_hex(),
2132 Some(&owner_hex),
2133 &target.to_hex(),
2134 crate::community::roles::Permissions::KICK,
2135 ) {
2136 return Err("not authorized to kick this member".to_string());
2137 }
2138 let target_hex = target.to_hex();
2150 let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
2151 let may_strip = authority.roles.can_act_on_member(
2152 &my_pk.to_hex(),
2153 Some(&owner_hex),
2154 &target_hex,
2155 crate::community::roles::Permissions::MANAGE_ROLES,
2156 );
2157 if holds_roles && may_strip {
2158 grant_roles(transport, community, target, Vec::new())
2159 .await
2160 .map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
2161 }
2162 let at_ms = now_ms();
2163 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2164 let citation = required_authority_citation(community, &my_pk)?;
2167 let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
2168 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
2169 .map_err(|e| e.to_string())?;
2170 transport.publish(&wrap, &community.relays).await?;
2171 Ok(())
2172 })
2173 .await
2174}
2175
2176#[derive(Debug, Default)]
2178pub struct KickSweep {
2179 pub kicked: Vec<PublicKey>,
2180 pub refused: Vec<(PublicKey, String)>,
2183 pub failed: Vec<PublicKey>,
2185}
2186
2187pub async fn kick_members<T: Transport + ?Sized>(
2198 transport: &T,
2199 community: &CommunityV2,
2200 targets: &[PublicKey],
2201 on_progress: &(dyn Fn(usize, usize) + Sync),
2202) -> Result<KickSweep, String> {
2203 crate::db::scoped(async move {
2204 assert_current_root(community)?;
2205 let mut out = KickSweep::default();
2206 if targets.is_empty() {
2207 return Ok(out);
2208 }
2209 let signer = crate::signer::active_signer()?;
2210 let my_pk = me_pk()?;
2211 let my_hex = my_pk.to_hex();
2212 let owner_hex = community.owner()?.to_hex();
2213 let authority = fetch_authority(transport, community).await;
2216 let citation = required_authority_citation(community, &my_pk)?;
2217 let gb_group =
2218 super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2219
2220 let total = targets.len();
2221 for (i, target) in targets.iter().enumerate() {
2222 let target_hex = target.to_hex();
2223 let outranks = |perm: u64| {
2224 authority.roles.can_act_on_member(&my_hex, Some(&owner_hex), &target_hex, perm)
2225 };
2226 if !outranks(crate::community::roles::Permissions::KICK) {
2227 out.refused.push((*target, "you do not outrank this member".to_string()));
2228 on_progress(i + 1, total);
2229 continue;
2230 }
2231 let holds_roles =
2236 authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
2237 if holds_roles && outranks(crate::community::roles::Permissions::MANAGE_ROLES) {
2238 if let Err(e) = grant_roles(transport, community, target, Vec::new()).await {
2239 out.refused.push((*target, format!("could not strip roles before kicking: {e}")));
2240 on_progress(i + 1, total);
2241 continue;
2242 }
2243 }
2244 let at_ms = now_ms();
2245 let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
2246 match guestbook::seal_guestbook_rumor_signed(
2247 &signer,
2248 my_pk,
2249 &rumor,
2250 &gb_group,
2251 Timestamp::from_secs(at_ms / 1000),
2252 )
2253 .await
2254 {
2255 Ok((wrap, _)) => match transport.publish(&wrap, &community.relays).await {
2256 Ok(_) => out.kicked.push(*target),
2257 Err(_) => out.failed.push(*target),
2258 },
2259 Err(_) => out.failed.push(*target),
2260 }
2261 on_progress(i + 1, total);
2262 }
2263 Ok(out)
2264 })
2265 .await
2266}
2267
2268pub struct AuthorityView {
2274 pub roles: crate::community::roles::CommunityRoles,
2275 pub banned: std::collections::BTreeSet<String>,
2276 pub gapped: bool,
2278 pub floored: std::collections::BTreeSet<String>,
2280 pub head_entities: std::collections::BTreeSet<String>,
2282 pub banned_at: std::collections::BTreeMap<String, u64>,
2284}
2285
2286pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
2292 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2293 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
2294 .unwrap_or_default()
2295 .into_iter()
2296 .filter(|(_, f)| f.0 == community.root_epoch.0)
2297 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2298 .collect();
2299 let control = control::ControlPlane::of(community);
2300
2301 let mut editions: Vec<ParsedEdition> = Vec::new();
2302 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2303 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2304 let mut oldest: Option<u64> = None;
2305 let mut until: Option<u64> = None;
2306 let mut a = fold_authority(community, &[], &floors);
2311 for _ in 0..FOLLOW_MAX_PAGES {
2312 let query = Query {
2316 kinds: vec![stream::KIND_WRAP],
2317 authors: vec![control.pk_hex()],
2318 until,
2319 limit: Some(FOLLOW_PAGE),
2320 evidence: crate::community::transport::Evidence::Quorum,
2321 ..Default::default()
2322 };
2323 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
2324 let mut fresh = 0usize;
2325 for w in &wraps {
2326 if !seen_wraps.insert(w.id) {
2327 continue;
2328 }
2329 fresh += 1;
2330 let at = w.created_at.as_secs();
2331 if oldest.is_none_or(|o| at < o) {
2332 oldest = Some(at);
2333 }
2334 if let Ok((ed, _)) = control.open(w) {
2335 if seen.insert(ed.inner_id) {
2336 editions.push(ed);
2337 }
2338 }
2339 }
2340 a = fold_authority(community, &editions, &floors);
2341 if !a.gapped || fresh == 0 {
2342 break;
2343 }
2344 until = oldest;
2345 }
2346 AuthorityView {
2347 roles: a.roles,
2348 banned: a.banned,
2349 gapped: a.gapped,
2350 floored: floors.keys().cloned().collect(),
2351 head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
2352 banned_at: a.banned_at,
2353 }
2354}
2355
2356async fn fetch_guestbook_events<T: Transport + ?Sized>(
2368 transport: &T,
2369 community: &CommunityV2,
2370 since_secs: u64,
2371) -> Result<(Vec<guestbook::GuestbookEvent>, u64, bool), String> {
2372 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2373 const GB_PAGE: usize = 500;
2374 const GB_MAX_PAGES: usize = 12;
2375 let mut events = Vec::new();
2376 let mut newest: u64 = since_secs;
2377 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2378 let mut until: Option<u64> = None;
2379 let mut oldest: Option<u64> = None;
2380 let mut reached_end = false;
2384 for _ in 0..GB_MAX_PAGES {
2385 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() };
2388 let wraps = transport.fetch(&query, &community.relays).await?;
2389 let mut fresh = 0usize;
2390 for wrap in &wraps {
2391 if !seen.insert(wrap.id) {
2392 continue;
2393 }
2394 fresh += 1;
2395 let at = wrap.created_at.as_secs();
2396 if oldest.is_none_or(|o| at < o) {
2397 oldest = Some(at);
2398 }
2399 if at > newest {
2400 newest = at;
2401 }
2402 if at < since_secs {
2404 continue;
2405 }
2406 if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
2407 if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
2408 events.push(ev);
2409 }
2410 }
2411 }
2412 if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
2413 reached_end = true;
2414 break;
2415 }
2416 match oldest {
2417 Some(o) if o > 0 => until = Some(o),
2418 _ => {
2420 reached_end = true;
2421 break;
2422 }
2423 }
2424 }
2425 Ok((events, newest, reached_end))
2426}
2427
2428fn snapshot_authority_for(cid_hex: &str, community: &CommunityV2, owner: &PublicKey) -> Option<PublicKey> {
2447 if community.root_epoch.0 == 0 {
2448 return None;
2449 }
2450 let minted_by = epoch_minters(cid_hex)
2451 .get(&community.root_epoch.0)
2452 .and_then(|h| PublicKey::from_hex(h).ok());
2453 Some(minted_by.unwrap_or(*owner))
2454}
2455
2456fn fold_members(
2457 community: &CommunityV2,
2458 events: &[guestbook::GuestbookEvent],
2459 mut observed: std::collections::BTreeMap<PublicKey, u64>,
2460 roles: &crate::community::roles::CommunityRoles,
2461 banlist: &std::collections::BTreeSet<PublicKey>,
2462 banned_at: &std::collections::BTreeMap<PublicKey, u64>,
2463) -> Result<Vec<PublicKey>, String> {
2464 let owner = community.owner()?;
2465 let owner_hex = owner.to_hex();
2466 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2467
2468 for g in &roles.grants {
2475 if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
2476 observed.entry(pk).or_insert(0);
2477 }
2478 }
2479
2480 let snapshot_authority_pk = snapshot_authority_for(&cid_hex, community, &owner);
2489 let snapshot_authority = snapshot_authority_pk.as_ref();
2490 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2494 let actor_hex = actor.to_hex();
2495 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2496 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2497 };
2498 let now = now_ms();
2499 let coalesced = guestbook::coalesce(events, now, snapshot_authority, &can_kick);
2500 let refound = guestbook::live_refound(events, snapshot_authority, now);
2504 let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at, refound.as_ref());
2505 if !banlist.contains(&owner) {
2507 members.insert(owner);
2508 }
2509 Ok(members.into_iter().collect())
2510}
2511
2512
2513#[derive(Debug, Default, serde::Serialize)]
2515pub struct RescueReport {
2516 pub hops: usize,
2518 pub hops_skipped: usize,
2520 pub chunks_published: usize,
2521 pub targets: usize,
2522}
2523
2524pub async fn rescue_stranded_members<T: Transport + ?Sized>(
2552 transport: &T,
2553 community: &CommunityV2,
2554 targets: &[PublicKey],
2555) -> Result<RescueReport, String> {
2556 crate::db::scoped(async move {
2557 let mut report = RescueReport { targets: targets.len(), ..Default::default() };
2558 if targets.is_empty() {
2559 return Ok(report);
2560 }
2561 let signer = crate::signer::active_signer()?;
2562 let my_pk = me_pk()?;
2563 let cid = community.id();
2564 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2565 let owner = community.owner()?;
2566 let owner_hex = owner.to_hex();
2567 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2571 if my_pk != owner && !roster.is_authorized(&my_pk.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN) {
2572 return Err("rescuing stranded members requires rotation authority (owner or BAN)".to_string());
2573 }
2574 let control_pk = community.control_pk.ok_or("rescue requires a split community (no control pk held)")?;
2575 let citation = my_authority_citation(community, &my_pk);
2576
2577 let mut roots: std::collections::BTreeMap<u64, [u8; 32]> =
2580 crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)?
2581 .into_iter()
2582 .map(|(e, k)| (e.0, k))
2583 .collect();
2584 roots.insert(community.root_epoch.0, community.community_root);
2585
2586 let at_secs = now_ms() / 1000;
2587 for e in 1..=community.root_epoch.0 {
2588 let (Some(prev_root), Some(new_root)) = (roots.get(&(e - 1)).copied(), roots.get(&e).copied()) else {
2589 crate::log_warn!("[Rescue] no archived root for hop {} -> {} — members wedged there stay wedged", e - 1, e);
2590 report.hops_skipped += 1;
2591 continue;
2592 };
2593 let group = super::derive::base_rekey_group_key(&prev_root, cid, Epoch(e));
2594 let prev_commit = super::derive::epoch_key_commitment(Epoch(e - 1), &prev_root);
2595 let existing = fetch_rekey_chunks(transport, &community.relays, &group).await?;
2600 let start_after = existing
2601 .iter()
2602 .filter(|c| {
2603 c.rotator == my_pk
2604 && c.scope == super::rekey::RekeyScope::Root
2605 && c.new_epoch == Epoch(e)
2606 && c.prev_commit == prev_commit
2607 })
2608 .map(|c| c.chunk.0.max(c.chunk.1))
2609 .max()
2610 .unwrap_or(0);
2611 let mut recipients: Vec<PublicKey> = targets.to_vec();
2615 if !recipients.contains(&my_pk) {
2616 recipients.push(my_pk);
2617 }
2618 let mut blobs = Vec::with_capacity(recipients.len());
2619 for r in &recipients {
2620 let staff = *r == my_pk || roster.is_staff(&r.to_hex(), Some(&owner_hex));
2622 let ctl_root = if staff { community.control_root.as_ref() } else { None };
2623 blobs.push(
2624 super::rekey::build_base_blob(&signer, &my_pk.to_bytes(), r, Epoch(e), &new_root, &control_pk.to_bytes(), ctl_root)
2625 .await
2626 .map_err(|e| e.to_string())?,
2627 );
2628 }
2629 let severed_hop = existing
2633 .iter()
2634 .any(|c| c.scope == super::rekey::RekeyScope::Root && c.new_epoch == Epoch(e) && c.severed)
2635 || sever_marks(&cid_hex).contains_key(&e);
2636 let chunks = super::rekey::build_rekey_chunk_extension(
2637 &signer,
2638 my_pk,
2639 &group,
2640 super::rekey::RekeyScope::Root,
2641 Epoch(e),
2642 Epoch(e - 1),
2643 &prev_commit,
2644 &blobs,
2645 at_secs,
2646 citation.as_ref(),
2647 severed_hop,
2648 start_after,
2649 )
2650 .await
2651 .map_err(|e| e.to_string())?;
2652 for c in &chunks {
2653 transport.publish_durable(c, &community.relays).await?;
2654 report.chunks_published += 1;
2655 }
2656 report.hops += 1;
2657 }
2658
2659 let gb_group = super::derive::guestbook_group_key(&community.community_root, cid, community.root_epoch);
2664 let snap_id = crate::community::random_32();
2665 for rumor in guestbook::build_snapshot_rumors(my_pk, targets, snap_id, at_secs * 1000) {
2666 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
2667 let _ = transport.publish(&wrap, &community.relays).await;
2668 }
2669 }
2670
2671 crate::log_info!(
2672 "[Rescue] {} member(s): {} hop(s) re-vended ({} skipped), {} chunk(s) published, snapshot addendum at epoch {}",
2673 targets.len(),
2674 report.hops,
2675 report.hops_skipped,
2676 report.chunks_published,
2677 community.root_epoch.0
2678 );
2679 Ok(report)
2680 })
2681 .await
2682}
2683
2684pub async fn wire_guestbook_members<T: Transport + ?Sized>(
2690 transport: &T,
2691 community: &CommunityV2,
2692) -> Result<Vec<PublicKey>, String> {
2693 let (events, _newest, _reached_end) = fetch_guestbook_events(transport, community, 0).await?;
2694 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2695 let owner = community.owner()?;
2696 let owner_hex = owner.to_hex();
2697 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2698 let snapshot_authority_pk = snapshot_authority_for(&cid_hex, community, &owner);
2699 let snapshot_authority = snapshot_authority_pk.as_ref();
2700 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2701 let actor_hex = actor.to_hex();
2702 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2703 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2704 };
2705 let coalesced = guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick);
2706 let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2707 .unwrap_or_default()
2708 .iter()
2709 .filter_map(|h| PublicKey::from_hex(h).ok())
2710 .collect();
2711 Ok(coalesced
2712 .iter()
2713 .filter(|(pk, st)| st.verdict == guestbook::Verdict::Joined && !banlist.contains(pk))
2714 .map(|(pk, _)| *pk)
2715 .collect())
2716}
2717
2718pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
2728 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2729 let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
2730 return false;
2731 };
2732 let Ok(owner) = community.owner() else { return false };
2733 let owner_hex = owner.to_hex();
2734 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2735 let snapshot_authority_pk = snapshot_authority_for(&cid_hex, community, &owner);
2736 let snapshot_authority = snapshot_authority_pk.as_ref();
2737 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2738 let actor_hex = actor.to_hex();
2739 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2740 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2741 };
2742 matches!(
2743 guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
2744 Some(st) if st.verdict == guestbook::Verdict::Kicked
2745 )
2746}
2747
2748pub async fn sync_guestbook<T: Transport + ?Sized>(
2753 transport: &T,
2754 community: &CommunityV2,
2755) -> Result<Vec<guestbook::GuestbookEvent>, String> {
2756 crate::db::scoped(async move {
2757 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2758 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2759 let full = !guestbook_walked_this_session(&cid_hex);
2767 let since = if full { 0 } else { cursor.saturating_sub(1) };
2770 let (fresh, newest, reached_end) = fetch_guestbook_events(transport, community, since).await?;
2771 let live = Some(community.root_epoch.0);
2775 let mut by_id: std::collections::HashMap<[u8; 32], usize> = events.iter().enumerate().map(|(i, e)| (e.rumor_id, i)).collect();
2776 let mut added = Vec::new();
2777 let mut restamped = false;
2778 for mut ev in fresh {
2779 ev.epoch = live;
2780 match by_id.get(&ev.rumor_id) {
2781 Some(&i) => {
2782 if events[i].epoch.is_none() {
2783 events[i].epoch = live;
2784 restamped = true;
2785 }
2786 }
2787 None => {
2788 let mut ev = ev;
2791 if ev.observed_at.is_none() {
2792 ev.observed_at = Some(
2793 web_time::SystemTime::now()
2794 .duration_since(web_time::UNIX_EPOCH)
2795 .map(|d| d.as_secs())
2796 .unwrap_or(0),
2797 );
2798 }
2799 by_id.insert(ev.rumor_id, events.len());
2800 events.push(ev.clone());
2801 added.push(ev);
2802 }
2803 }
2804 }
2805 let advanced = if reached_end { newest.max(cursor) } else { cursor };
2809 if !added.is_empty() || advanced > cursor || restamped {
2810 crate::db::community::set_guestbook(&cid_hex, &events, advanced)?;
2811 mark_guestbook_observed_since(&cid_hex);
2812 }
2813 if full && reached_end {
2814 mark_guestbook_walked(&cid_hex);
2815 }
2816 Ok(added)
2817 })
2818 .await
2819}
2820
2821struct GuestbookWalked;
2825
2826fn guestbook_walked_set() -> std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>> {
2827 crate::db::current_session().scoped::<GuestbookWalked, _>()
2828}
2829
2830fn guestbook_walked_this_session(community_id: &str) -> bool {
2831 guestbook_walked_set().lock().is_ok_and(|s| s.contains(community_id))
2832}
2833
2834fn mark_guestbook_walked(community_id: &str) {
2835 if let Ok(mut s) = guestbook_walked_set().lock() {
2836 s.insert(community_id.to_string());
2837 }
2838}
2839
2840pub fn ingest_guestbook_event(community: &CommunityV2, mut ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
2843 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2844 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2845 if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
2846 return Ok(false);
2847 }
2848 ev.epoch = Some(community.root_epoch.0);
2849 events.push(ev);
2850 crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
2851 Ok(true)
2852}
2853
2854fn guestbook_observed_since(cid_hex: &str) -> Option<u64> {
2867 crate::db::settings::get_sql_setting(format!("gb_observed_since:{cid_hex}"))
2868 .ok()
2869 .flatten()
2870 .and_then(|v| v.parse::<u64>().ok())
2871}
2872
2873fn mark_guestbook_observed_since(cid_hex: &str) {
2874 let key = format!("gb_observed_since:{cid_hex}");
2875 if crate::db::settings::get_sql_setting(key.clone()).ok().flatten().is_some() {
2876 return; }
2878 let now = web_time::SystemTime::now()
2879 .duration_since(web_time::UNIX_EPOCH)
2880 .map(|d| d.as_secs())
2881 .unwrap_or(0);
2882 let _ = crate::db::settings::set_sql_setting(key, now.to_string());
2883}
2884
2885pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2886 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2887 let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2888 stored_memberlist_from(community, &events)
2889}
2890
2891pub fn stored_memberlist_from(community: &CommunityV2, events: &[guestbook::GuestbookEvent]) -> Result<Vec<PublicKey>, String> {
2894 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2895 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2896 for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
2897 if let Ok(pk) = PublicKey::parse(&npub) {
2898 observed.insert(pk, last_active_secs.saturating_mul(1000));
2899 }
2900 }
2901 let roles = crate::db::community::get_community_roles(&cid_hex)?;
2902 let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2903 .unwrap_or_default()
2904 .iter()
2905 .filter_map(|h| PublicKey::from_hex(h).ok())
2906 .collect();
2907 let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
2910 .unwrap_or_default()
2911 .into_iter()
2912 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2913 .collect();
2914 fold_members(community, events, observed, &roles, &banlist, &banned_at)
2915}
2916
2917pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2923 let (events, _newest, _reached_end) = fetch_guestbook_events(transport, community, 0).await?;
2924 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2927 for ch in &community.channels {
2928 if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
2929 for f in &page {
2930 let e = observed.entry(f.event.opened().author).or_insert(0);
2931 *e = (*e).max(f.event.opened().at_ms);
2932 }
2933 }
2934 }
2935
2936 let authority = fetch_authority(transport, community).await;
2941 let banlist: std::collections::BTreeSet<PublicKey> =
2943 authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
2944 let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
2948 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
2949 )
2950 .unwrap_or_default()
2951 .into_iter()
2952 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2953 .collect();
2954 for (h, at) in &authority.banned_at {
2955 if let Ok(pk) = PublicKey::from_hex(h) {
2956 let slot = banned_at.entry(pk).or_insert(0);
2957 *slot = (*slot).max(*at);
2958 }
2959 }
2960 fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
2961}
2962
2963pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2974 crate::db::scoped(async move {
2975 let signer = crate::signer::active_signer()?;
2976 let my_pk = me_pk()?;
2977 if community.owner()? != my_pk {
2978 return Err("only the owner can dissolve a community".to_string());
2979 }
2980 let at_ms = now_ms();
2981 let at = at_ms / 1000;
2982 let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
2983 let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
2984 transport.publish_durable(&wrap, &community.relays).await?;
2986 crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2987 if let Err(e) = tombstone_community_list(transport, community.id(), &community.relays, at_ms).await {
2996 crate::log_net_fail!("[CommunityList] dissolve tombstone failed ({}) — retrying in the background", e);
2997 tombstone_community_list_durable(*community.id(), community.relays.clone(), at_ms);
2998 }
2999 Ok(())
3000 })
3001 .await
3002}
3003
3004pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
3009 let group = super::derive::dissolved_group_key(community.id());
3010 let query = Query {
3011 kinds: vec![stream::KIND_WRAP],
3012 authors: vec![group.pk_hex()],
3013 limit: Some(20),
3014 ..Default::default()
3015 };
3016 let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
3017 return false;
3018 };
3019 wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
3020}
3021
3022pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
3035 refound_community_with(transport, community, removed, false, 0, None).await
3036}
3037
3038pub async fn refound_community_severing<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey], joined_after_cut: u64) -> Result<CommunityV2, String> {
3044 refound_community_with(transport, community, removed, true, joined_after_cut, None).await
3045}
3046
3047pub async fn refound_community_retaining<T: Transport + ?Sized>(
3061 transport: &T,
3062 community: &CommunityV2,
3063 removed: &[PublicKey],
3064 retain: &[PublicKey],
3065) -> Result<CommunityV2, String> {
3066 refound_community_with(transport, community, removed, false, 0, Some(retain)).await
3067}
3068
3069async fn refound_community_with<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey], sever: bool, joined_after_cut: u64, allow_only: Option<&[PublicKey]>) -> Result<CommunityV2, String> {
3070 crate::db::scoped(async move {
3071 let cid = community.id();
3072 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
3073 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
3075 return Err("this community has been dissolved; it cannot be re-founded".to_string());
3076 }
3077 let signer = crate::signer::active_signer()?;
3078 let my_pk = me_pk()?;
3079 let lock = super::realtime::follow_lock(cid);
3083 let _guard = lock.lock().await;
3084 let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
3088 let community = &fresh;
3089 let owner = community.owner()?;
3090
3091 {
3098 let owner_hex = owner.to_hex();
3099 let me_hex = my_pk.to_hex();
3100 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3106 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3107 let authorized = my_pk == owner
3108 || (!banned.contains(&me_hex)
3109 && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
3110 && removed.iter().all(|t| {
3111 roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
3112 }));
3113 if !authorized {
3114 return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
3115 }
3116 }
3117
3118 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
3122 .into_iter()
3123 .filter(|(_, f)| f.0 == community.root_epoch.0)
3124 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
3125 .collect();
3126 let current_control = control::ControlPlane::of(community);
3127 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
3134 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
3135 let mut oldest: Option<u64> = None;
3136 let mut until: Option<u64> = None;
3137 let mut truncated = false;
3141 for page in 0..COMPACT_MAX_PAGES {
3142 let query = Query {
3146 kinds: vec![stream::KIND_WRAP],
3147 authors: vec![current_control.pk_hex()],
3148 until,
3149 limit: Some(FOLLOW_PAGE),
3150 evidence: crate::community::transport::Evidence::Full,
3151 ..Default::default()
3152 };
3153 let wraps = transport.fetch(&query, &community.relays).await?;
3154 let mut fresh = 0usize;
3155 for w in &wraps {
3156 if !seen_wraps.insert(w.id) {
3157 continue;
3158 }
3159 fresh += 1;
3160 let at = w.created_at.as_secs();
3161 if oldest.is_none_or(|o| at < o) {
3162 oldest = Some(at);
3163 }
3164 if let Ok(parsed) = current_control.open(w) {
3165 opened.push(parsed);
3166 }
3167 }
3168 if fresh == 0 {
3169 truncated = wraps.len() >= FOLLOW_PAGE;
3173 break;
3174 }
3175 until = oldest;
3176 if page + 1 == COMPACT_MAX_PAGES {
3177 truncated = true;
3178 }
3179 }
3180 if truncated {
3181 return Err(
3182 "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(),
3183 );
3184 }
3185
3186 let prev_epoch = community.root_epoch;
3187 let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
3188 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
3189 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
3194 let new_control_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::CONTROL_ROOT_SCOPE, new_epoch.0)?;
3199 let new_control_pk = super::derive::control_signer_group_key(&new_control_root, cid, new_epoch).pk();
3200 let new_control = control::split_write_group(&new_control_root, &new_root, cid, new_epoch);
3206 let at = now_ms();
3207 let at_secs = at / 1000;
3208
3209 use std::collections::BTreeMap;
3215 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
3216 for (i, (e, _)) in opened.iter().enumerate() {
3217 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
3218 }
3219 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
3220 for (floor_key, floor) in &floors {
3221 let head_idx = by_eid
3229 .get(floor_key)
3230 .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
3231 let Some(head_idx) = head_idx else {
3232 return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
3233 };
3234 let (head_ed, head_os) = &opened[head_idx];
3235 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
3236 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
3237 carried.push((h, rewrapped));
3238 }
3239
3240 let members = memberlist(transport, community).await?;
3249 let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
3250 let net_len = members.len();
3251 let mut union: std::collections::BTreeSet<PublicKey> = members.into_iter().collect();
3252 let local = stored_memberlist(community)?;
3253 let local_len = local.len();
3254 union.extend(local);
3255 let mut recipients: Vec<PublicKey> = union.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
3256 if !recipients.contains(&my_pk) {
3257 recipients.push(my_pk);
3258 }
3259
3260 let mut roster_for_channels = fetch_authority(transport, community).await.roles;
3268 {
3269 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3270 for r in cached.roles {
3271 if !roster_for_channels.roles.iter().any(|x| x.role_id == r.role_id) {
3272 roster_for_channels.roles.push(r);
3273 }
3274 }
3275 for g in cached.grants {
3276 if !roster_for_channels.grants.iter().any(|x| x.member == g.member) {
3277 roster_for_channels.grants.push(g);
3278 }
3279 }
3280 }
3281 let owner_hex_for_channels = community.owner().ok().map(|o| o.to_hex());
3282
3283 if let Some(allow) = allow_only {
3291 let allowed: std::collections::HashSet<[u8; 32]> = allow.iter().map(|p| p.to_bytes()).collect();
3292 let before = recipients.len();
3293 recipients.retain(|r| {
3294 *r == my_pk
3295 || Some(r.to_hex()) == owner_hex_for_channels
3296 || allowed.contains(&r.to_bytes())
3297 });
3298 let dropped = before.saturating_sub(recipients.len());
3299 if dropped > 0 {
3300 crate::log_info!(
3301 "[Refound {}] purge: {} member(s) not on the retain list vended no key",
3302 &cid_hex[..8.min(cid_hex.len())], dropped
3303 );
3304 }
3305 }
3306
3307 if joined_after_cut > 0 {
3320 match fetch_guestbook_events(transport, community, 0).await {
3321 Ok((events, _, _)) => {
3322 let mut first_seen: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
3323 for ev in &events {
3324 if let guestbook::GuestbookEntry::Join { member, at_ms, .. } = &ev.entry {
3325 let secs = at_ms / 1000;
3326 first_seen.entry(*member).and_modify(|e| *e = (*e).min(secs)).or_insert(secs);
3327 }
3328 }
3329 if guestbook_observed_since(&cid_hex).is_some_and(|since| since < joined_after_cut) {
3336 if let Ok((local, _)) = crate::db::community::get_guestbook(&cid_hex) {
3337 for ev in &local {
3338 let (guestbook::GuestbookEntry::Join { member, .. }, Some(seen)) = (&ev.entry, ev.observed_at) else {
3339 continue;
3340 };
3341 first_seen.entry(*member).and_modify(|e| *e = (*e).max(seen)).or_insert(seen);
3344 }
3345 }
3346 }
3347 let before = recipients.len();
3348 recipients.retain(|r| {
3349 if *r == my_pk || Some(r.to_hex()) == owner_hex_for_channels {
3350 return true;
3351 }
3352 if roster_for_channels.is_staff(&r.to_hex(), owner_hex_for_channels.as_deref()) {
3354 return true;
3355 }
3356 first_seen.get(r).is_none_or(|joined| *joined < joined_after_cut)
3357 });
3358 let dropped = before.saturating_sub(recipients.len());
3359 if dropped > 0 {
3360 crate::log_info!(
3361 "[Refound {}] containment: {} raid-window joiner(s) vended no key",
3362 &cid_hex[..8.min(cid_hex.len())], dropped
3363 );
3364 }
3365 }
3366 Err(e) => crate::log_warn!(
3367 "[Refound {}] guestbook unreadable — raid-window joiners NOT filtered, only the named cut is severed ({e})",
3368 &cid_hex[..8.min(cid_hex.len())]
3369 ),
3370 }
3371 }
3372
3373 {
3390 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3392 let missing: Vec<String> = roster_for_channels
3393 .grants
3394 .iter()
3395 .filter(|g| !g.role_ids.is_empty())
3396 .filter_map(|g| PublicKey::from_hex(&g.member).ok())
3397 .filter(|pk| !removed_set.contains(&pk.to_bytes()))
3398 .filter(|pk| !banned.contains(&pk.to_hex()))
3399 .filter(|pk| !recipients.contains(pk))
3400 .map(|pk| pk.to_hex())
3401 .collect();
3402 if !missing.is_empty() {
3403 return Err(format!(
3407 "re-founding aborted: {} granted member(s) would be vended no key and stranded at epoch {} — {}; \
3408 recipients={} (network fold {}, local fold {}); no state published",
3409 missing.len(),
3410 prev_epoch.0,
3411 missing.iter().map(|h| h[..8.min(h.len())].to_string()).collect::<Vec<_>>().join(", "),
3412 recipients.len(),
3413 net_len,
3414 local_len
3415 ));
3416 }
3417 }
3418
3419 let mut base_blobs = Vec::new();
3427 for r in &recipients {
3428 let staff = *r == my_pk || roster_for_channels.is_staff(&r.to_hex(), owner_hex_for_channels.as_deref());
3429 base_blobs.push(
3430 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))
3431 .await
3432 .map_err(|e| e.to_string())?,
3433 );
3434 }
3435 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
3436 let base_chunks =
3437 super::rekey::build_rekey_chunks(&signer, my_pk, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), sever)
3438 .await
3439 .map_err(|e| e.to_string())?;
3440
3441 let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
3448 let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
3449 for ch in &community.channels {
3450 let (Some(old_key), true) = (ch.key, ch.private) else { continue };
3451 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
3452 let entitled: Vec<PublicKey> = recipients
3453 .iter()
3454 .copied()
3455 .filter(|r| {
3456 *r == my_pk
3457 || roster_for_channels.is_entitled(owner_hex_for_channels.as_deref(), &r.to_hex(), &ch_hex, &[], &[])
3458 })
3459 .collect();
3460 let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
3461 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)?;
3465 let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
3466 let mut ch_blobs = Vec::new();
3467 for r in &entitled {
3468 ch_blobs.push(
3469 super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
3470 .await
3471 .map_err(|e| e.to_string())?,
3472 );
3473 }
3474 let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
3475 let ch_chunks = super::rekey::build_rekey_chunks(&signer, my_pk, &ch_group, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, ch.epoch, &ch_prev_commit, &ch_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
3476 .await
3477 .map_err(|e| e.to_string())?;
3478 channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
3479 channel_chunk_sets.push(ch_chunks);
3480 }
3481
3482 for c in &base_chunks {
3485 transport.publish_durable(c, &community.relays).await?;
3486 }
3487 for set in &channel_chunk_sets {
3488 for c in set {
3489 transport.publish_durable(c, &community.relays).await?;
3490 }
3491 }
3492 for (_, wrap) in &carried {
3493 transport.publish_durable(wrap, &community.relays).await?;
3494 }
3495 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
3498 let snap_id = crate::community::random_32();
3499 for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
3500 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
3501 let _ = transport.publish(&wrap, &community.relays).await;
3502 }
3503 }
3504
3505 if crate::db::community::community_protocol(cid)?.is_none() {
3507 return Ok(community.clone()); }
3509 let mut updated = community.clone();
3513 updated.community_root = new_root;
3514 updated.root_epoch = new_epoch;
3515 updated.control_pk = Some(new_control_pk);
3516 updated.control_root = Some(new_control_root);
3517 for (id, key, ep) in &channel_updates {
3518 if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
3519 c.key = Some(*key);
3520 c.epoch = *ep;
3521 }
3522 }
3523 crate::db::community::save_community_v2(&updated)?;
3524 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
3529 for (h, _) in &carried {
3530 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
3531 }
3532 if let Some(client) = crate::state::nostr_client() {
3538 super::realtime::refresh_subscription(&client).await;
3539 }
3540 record_epoch_minter(&cid_hex, new_epoch.0, &my_pk.to_hex());
3544 if sever {
3545 add_sever_mark(&cid_hex, new_epoch.0, at_secs);
3549 for attempt in 0..3u8 {
3550 match sever_own_links(transport, &updated).await {
3551 Ok(_) => break,
3552 Err(e) if attempt == 2 => {
3553 crate::log_warn!("v2: post-severing link revocation failed after retries ({e}); the sever mark retries it on the next follow");
3554 }
3555 Err(_) => continue,
3556 }
3557 }
3558 } else {
3559 for attempt in 0..3u8 {
3565 match refresh_public_links(transport, &updated).await {
3566 Ok(()) => break,
3567 Err(e) if attempt == 2 => {
3568 crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
3569 }
3570 Err(_) => continue,
3571 }
3572 }
3573 }
3574 Ok(updated)
3575 })
3576 .await
3577}
3578
3579pub async fn refound_at_birth<T: Transport + ?Sized>(
3596 transport: &T,
3597 community: &CommunityV2,
3598 snapshot_members: &[PublicKey],
3599) -> Result<CommunityV2, String> {
3600 crate::db::scoped(async move {
3601 let cid = community.id();
3602 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
3603 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
3606 return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
3607 }
3608 let signer = crate::signer::active_signer()?;
3609 let my_pk = me_pk()?;
3610 if my_pk != community.owner()? {
3611 return Err("only the owner can birth-refound the migration twin".to_string());
3612 }
3613 let lock = super::realtime::follow_lock(cid);
3614 let _guard = lock.lock().await;
3615 let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
3616 if community.root_epoch.0 == 1 {
3622 return Ok(community);
3623 }
3624 if community.root_epoch.0 != 0 {
3625 return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
3626 }
3627 let community = &community;
3628
3629 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
3633 .into_iter()
3634 .filter(|(_, f)| f.0 == community.root_epoch.0)
3635 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
3636 .collect();
3637 let current_control = control::ControlPlane::of(community);
3638 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
3639 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
3640 let mut oldest: Option<u64> = None;
3641 let mut until: Option<u64> = None;
3642 let mut truncated = false;
3644 for page in 0..COMPACT_MAX_PAGES {
3645 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() };
3646 let wraps = transport.fetch(&query, &community.relays).await?;
3647 let mut fresh = 0usize;
3648 for w in &wraps {
3649 if !seen_wraps.insert(w.id) { continue; }
3650 fresh += 1;
3651 let at = w.created_at.as_secs();
3652 if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
3653 if let Ok(parsed) = current_control.open(w) {
3654 opened.push(parsed);
3655 }
3656 }
3657 if fresh == 0 {
3658 truncated = wraps.len() >= FOLLOW_PAGE;
3659 break;
3660 }
3661 until = oldest;
3662 if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
3663 }
3664 if truncated {
3665 return Err(
3666 "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(),
3667 );
3668 }
3669
3670 let prev_epoch = community.root_epoch; let new_epoch = Epoch(1);
3672 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
3673 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
3674 let new_control_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::CONTROL_ROOT_SCOPE, new_epoch.0)?;
3676 let new_control_pk = super::derive::control_signer_group_key(&new_control_root, cid, new_epoch).pk();
3677 let new_control = control::split_write_group(&new_control_root, &new_root, cid, new_epoch);
3678 let at = now_ms();
3679 let at_secs = at / 1000;
3680
3681 use std::collections::BTreeMap;
3682 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
3683 for (i, (e, _)) in opened.iter().enumerate() {
3684 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
3685 }
3686 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
3687 for (floor_key, floor) in &floors {
3688 let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
3689 let Some(head_idx) = head_idx else {
3690 return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
3691 };
3692 let (head_ed, head_os) = &opened[head_idx];
3693 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
3694 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
3695 carried.push((h, rewrapped));
3696 }
3697
3698 let base_blobs = vec![
3701 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))
3702 .await
3703 .map_err(|e| e.to_string())?,
3704 ];
3705 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
3706 let base_chunks =
3707 super::rekey::build_rekey_chunks(&signer, my_pk, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
3708 .await
3709 .map_err(|e| e.to_string())?;
3710
3711 for c in &base_chunks {
3713 transport.publish_durable(c, &community.relays).await?;
3714 }
3715 for (_, wrap) in &carried {
3716 transport.publish_durable(wrap, &community.relays).await?;
3717 }
3718 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
3726 let snap_id = crate::community::random_32();
3727 let snapshot_wraps: Vec<Event> = {
3728 let mut out = Vec::new();
3729 for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
3730 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
3731 .await
3732 .map_err(|e| format!("seal birth snapshot: {e}"))?;
3733 out.push(wrap);
3734 }
3735 out
3736 };
3737 for wrap in &snapshot_wraps {
3738 transport.publish_durable(wrap, &community.relays).await?;
3739 }
3740 let verify_view = {
3749 let mut v = community.clone();
3750 v.community_root = new_root;
3751 v.root_epoch = new_epoch;
3752 v.control_pk = Some(new_control_pk);
3753 v.control_root = Some(new_control_root);
3754 v
3755 };
3756 let expected: Vec<PublicKey> = {
3757 let banlist = fetch_authority(transport, &verify_view).await.banned;
3758 snapshot_members.iter().copied()
3759 .filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
3760 .collect()
3761 };
3762 if !expected.is_empty() {
3763 let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
3764 let missing = expected.iter().filter(|m| !folded.contains(m)).count();
3765 if missing > 0 {
3766 return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
3767 }
3768 }
3769
3770 if crate::db::community::community_protocol(cid)?.is_none() {
3772 return Ok(community.clone());
3773 }
3774 let mut updated = community.clone();
3775 updated.community_root = new_root;
3776 updated.root_epoch = new_epoch;
3777 updated.control_pk = Some(new_control_pk);
3778 updated.control_root = Some(new_control_root);
3779 crate::db::community::save_community_v2(&updated)?;
3780 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
3781 for (h, _) in &carried {
3782 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
3783 }
3784 Ok(updated)
3785 })
3786 .await
3787}
3788
3789fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
3796 if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
3797 return Ok(existing);
3798 }
3799 let fresh = crate::community::random_32();
3800 crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
3801 Ok(fresh)
3802}
3803
3804fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
3810 let hex = crate::simd::hex::bytes_to_hex_32;
3811 let channels = community
3812 .channels
3813 .iter()
3814 .filter(|c| c.private)
3815 .filter_map(|c| {
3819 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() })
3820 })
3821 .collect();
3822 super::list::JoinMaterial {
3823 community_id: hex(&community.identity.community_id.0),
3824 owner: hex(&community.identity.owner_xonly),
3825 owner_salt: hex(&community.identity.owner_salt),
3826 community_root: hex(&community.community_root),
3827 root_epoch: community.root_epoch.0,
3828 control_pk: community.control_pk.map(|p| p.to_hex()),
3829 control_root: community.control_root.map(|r| hex(&r)),
3833 channels,
3834 relays: community.relays.clone(),
3835 name: community.name.clone(),
3836 extra: Default::default(),
3837 }
3838}
3839
3840fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
3844 let channels = jm
3847 .channels
3848 .iter()
3849 .filter_map(|c| {
3850 c.key.as_ref().map(|k| invite::ChannelGrant { id: c.id.clone(), key: k.clone(), epoch: c.epoch, name: c.name.clone() })
3851 })
3852 .collect();
3853 CommunityInvite {
3854 community_id: jm.community_id.clone(),
3855 owner: jm.owner.clone(),
3856 owner_salt: jm.owner_salt.clone(),
3857 community_root: jm.community_root.clone(),
3858 root_epoch: jm.root_epoch,
3859 control_pk: jm.control_pk.clone(),
3860 channels,
3861 relays: jm.relays.clone(),
3862 name: jm.name.clone(),
3863 icon: None,
3864 expires_at: None,
3865 creator_npub: None,
3866 label: None,
3867 extra: Default::default(),
3868 }
3869}
3870
3871async fn own_list_relays() -> Vec<String> {
3876 let Some(client) = crate::state::nostr_client() else { return Vec::new() };
3877 client
3878 .relays()
3879 .await
3880 .iter()
3881 .filter(|(_, r)| r.capabilities().load().can_write())
3882 .map(|(url, _)| url.to_string())
3883 .collect()
3884}
3885
3886async fn list_read_relays(extra: &[String]) -> Vec<String> {
3891 let mut set = own_list_relays().await;
3892 set.extend(invite::stock_relays());
3893 set.extend(held_v2_relays());
3894 set.extend(extra.iter().cloned());
3895 set.sort();
3896 set.dedup();
3897 set
3898}
3899
3900pub struct FragSet {
3903 pub list: super::list::CommunityList,
3904 pub created_at: std::collections::BTreeMap<usize, u64>,
3905 pub declared: usize,
3907 pub read_frags: std::collections::BTreeMap<usize, super::list_frag::FragList>,
3910}
3911
3912impl FragSet {
3913 pub fn is_complete(&self) -> bool {
3916 (0..self.declared).all(|i| self.created_at.contains_key(&i))
3917 }
3918}
3919
3920async fn fetch_fragments<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<FragSet>, String> {
3923 let signer = crate::signer::active_signer()?;
3924 let my_pk = me_pk()?;
3925 let query = Query {
3926 kinds: vec![super::kind::COMMUNITY_LIST_FRAG],
3927 authors: vec![my_pk.to_hex()],
3928 limit: Some(64),
3929 ..Default::default()
3930 };
3931 let events = transport.fetch(&query, relays).await?;
3932 if events.is_empty() {
3933 return Ok(None);
3934 }
3935 let mut newest: std::collections::BTreeMap<usize, (u64, [u8; 32], super::list_frag::FragList)> = Default::default();
3936 let mut undecryptable = 0usize;
3937 for e in events {
3938 let at = e.created_at.as_secs();
3939 let id = *e.id.as_bytes();
3940 match super::list_frag::parse_fragment_event(&signer, my_pk, &e).await {
3941 Ok((index, frag)) => {
3942 let wins = match newest.get(&index) {
3945 None => true,
3946 Some((prev_at, prev_id, _)) => at > *prev_at || (at == *prev_at && id < *prev_id),
3947 };
3948 if wins {
3949 newest.insert(index, (at, id, frag));
3950 }
3951 }
3952 Err(_) => undecryptable += 1,
3953 }
3954 }
3955 if newest.is_empty() {
3956 crate::log_warn!("[CommunityList] {} fragment(s) fetched, none readable — treating as no news", undecryptable);
3957 return Ok(None);
3958 }
3959 let declared = newest
3963 .values()
3964 .max_by_key(|(at, _, f)| (*at, f.frags))
3965 .map(|(_, _, f)| f.frags)
3966 .unwrap_or(1)
3967 .max(1);
3968 let read_frags: std::collections::BTreeMap<usize, super::list_frag::FragList> =
3969 newest.iter().map(|(i, (_, _, f))| (*i, f.clone())).collect();
3970 let frags: Vec<_> = read_frags.values().cloned().collect();
3971 let set = FragSet {
3972 list: super::list_frag::defragment(&frags),
3973 created_at: newest.iter().map(|(i, (at, _, _))| (*i, *at)).collect(),
3974 declared,
3975 read_frags,
3976 };
3977 if !set.is_complete() {
3978 crate::log_warn!(
3979 "[CommunityList] INCOMPLETE: hold {} of {} fragment(s) — reading, refusing to write",
3980 set.created_at.len(),
3981 declared
3982 );
3983 }
3984 Ok(Some(set))
3985}
3986
3987async fn publish_fragments<T: Transport + ?Sized>(
3992 transport: &T,
3993 list: &super::list::CommunityList,
3994 prev: &std::collections::BTreeMap<usize, u64>,
3995 read: &std::collections::BTreeMap<usize, super::list_frag::FragList>,
3996 own: &[String],
3997) -> Result<usize, String> {
3998 let signer = crate::signer::active_signer()?;
3999 let my_pk = me_pk()?;
4000 let frags = super::list_frag::fragment(list);
4001 let now = now_ms() / 1000;
4002 let unchanged = |index: usize, frag: &super::list_frag::FragList| -> bool {
4006 let Some(old) = read.get(&index) else { return false };
4007 matches!(
4008 (serde_json::to_string(old), serde_json::to_string(frag)),
4009 (Ok(a), Ok(b)) if a == b
4010 )
4011 };
4012 crate::log_net_info!(
4015 "[CommunityList] publishing {} fragment(s): {} live entries ({} retired), {} tombstones",
4016 frags.len(),
4017 frags.iter().map(|f| f.entries.len()).sum::<usize>(),
4018 list.entries.len() - frags.iter().map(|f| f.entries.len()).sum::<usize>(),
4019 frags.iter().map(|f| f.tombstones.len()).sum::<usize>(),
4020 );
4021 let mut skipped = 0usize;
4022 for (index, frag) in frags.iter().enumerate() {
4023 if unchanged(index, frag) {
4024 skipped += 1;
4025 continue;
4026 }
4027 let created_at = now.max(prev.get(&index).copied().unwrap_or(0) + 1);
4028 let event = super::list_frag::build_fragment_event(&signer, my_pk, frag, index, created_at).await?;
4029 publish_list_event(transport, &event, own).await?;
4030 }
4031 for index in frags.len()..prev.len() {
4034 let empty = super::list_frag::FragList {
4035 frags: frags.len(),
4036 entries: vec![],
4037 tombstones: vec![],
4038 extra: Default::default(),
4039 };
4040 if unchanged(index, &empty) {
4041 skipped += 1;
4042 continue;
4043 }
4044 let created_at = now.max(prev.get(&index).copied().unwrap_or(0) + 1);
4045 let event = super::list_frag::build_fragment_event(&signer, my_pk, &empty, index, created_at).await?;
4046 publish_list_event(transport, &event, own).await?;
4047 }
4048 if skipped > 0 {
4049 crate::log_net_info!("[CommunityList] {} fragment(s) skipped — byte-identical to the relay copy", skipped);
4050 }
4051 Ok(frags.len())
4052}
4053
4054async fn publish_list_event<T: Transport + ?Sized>(transport: &T, event: &Event, own: &[String]) -> Result<(), String> {
4058 if !own.is_empty() {
4059 match transport.publish(event, own).await {
4060 Ok(()) => return Ok(()),
4061 Err(e) => crate::log_warn!("[CommunityList] own relays refused the list ({}) — falling back to the stock set", e),
4062 }
4063 }
4064 transport.publish(event, &invite::stock_relays()).await
4065}
4066
4067fn held_v2_relays() -> Vec<String> {
4070 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4071 if let Ok(ids) = crate::db::community::list_community_ids() {
4072 for id in ids {
4073 if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
4074 if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
4075 set.extend(c.relays);
4076 }
4077 }
4078 }
4079 }
4080 set.into_iter().collect()
4081}
4082
4083pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
4095 crate::db::scoped(async move {
4096 let held = held_v2_relays();
4097 if held.is_empty() {
4098 return Ok(false); }
4100 let relays = list_read_relays(&[]).await;
4101 let own = own_list_relays().await;
4102 let write_relays = if own.is_empty() { held.clone() } else { own };
4106 let (remote, prev_created, read_frags) = match fetch_fragments(transport, &relays).await {
4110 Ok(None) => (super::list::CommunityList::default(), Default::default(), Default::default()),
4113 Ok(Some(set)) if !set.is_complete() => {
4114 crate::log_net_fail!(
4115 "[CommunityList] republish SKIPPED — hold {} of {} fragments; rewriting a set we haven't fully read would drop the memberships in the ones we're missing",
4116 set.created_at.len(),
4117 set.declared
4118 );
4119 return Ok(false);
4120 }
4121 Ok(Some(set)) => (set.list, set.created_at, set.read_frags),
4122 Err(e) => {
4123 crate::log_warn!(
4129 "[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
4130 e,
4131 just_joined
4132 .map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
4133 .unwrap_or_default()
4134 );
4135 return Ok(false);
4136 }
4137 };
4138 let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
4139 let now = now_ms();
4140 let mut local = super::list::CommunityList::default();
4141 for id in crate::db::community::list_community_ids()? {
4142 if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
4143 continue;
4144 }
4145 let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
4146 let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
4147 let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
4148 let tombstoned_at = remote
4160 .tombstones
4161 .iter()
4162 .find(|t| t.community_id == cid_hex)
4163 .map(|t| t.removed_at)
4164 .unwrap_or(0);
4165 let held_since = c.created_at_ms;
4166 if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
4167 crate::log_warn!(
4168 "[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
4169 &cid_hex[..8], tombstoned_at, held_since
4170 );
4171 continue;
4172 }
4173 let added_at = if remote.is_live(&cid_hex) && !is_join {
4178 remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
4179 } else if !is_join && tombstoned_at > 0 {
4180 held_since
4181 } else {
4182 now
4183 };
4184 let jm = join_material(&c);
4185 local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
4186 }
4187 let merged = remote.merge(&local);
4188 match publish_fragments(transport, &merged, &prev_created, &read_frags, &write_relays).await {
4191 Ok(_) => {}
4192 Err(e) => {
4193 crate::log_net_fail!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
4194 return Err(e);
4195 }
4196 }
4197 Ok(true)
4198 })
4199 .await
4200}
4201
4202const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
4207
4208pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
4221 if crate::state::nostr_client().is_none() {
4222 return;
4223 }
4224 crate::db::spawn_bound(async move {
4225 for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
4226 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4227 match republish_community_list(&transport, just_joined.as_ref()).await {
4228 Ok(true) => {
4229 if attempt > 0 {
4230 crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
4231 }
4232 return;
4233 }
4234 Ok(false) => {} Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
4236 }
4237 crate::rt::time::sleep(std::time::Duration::from_secs(*wait)).await;
4238 }
4239 crate::log_warn!(
4240 "[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
4241 LIST_REPUBLISH_BACKOFF_SECS.len()
4242 );
4243 });
4244}
4245
4246async fn tombstone_community_list<T: Transport + ?Sized>(
4251 transport: &T,
4252 community_id: &crate::community::CommunityId,
4253 relays: &[String],
4254 removed_at: u64,
4255) -> Result<(), String> {
4256 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
4257 let read_relays = list_read_relays(relays).await;
4258 let own = own_list_relays().await;
4259 let write_relays = if own.is_empty() { relays.to_vec() } else { own };
4260 let (mut doc, prev_created, read_frags) = match fetch_fragments(transport, &read_relays).await {
4264 Ok(Some(set)) if !set.is_complete() => {
4265 return Err(format!("hold {} of {} fragments — refusing to rewrite a set we haven't fully read", set.created_at.len(), set.declared));
4266 }
4267 Ok(Some(set)) => (set.list, set.created_at, set.read_frags),
4268 Ok(None) => (super::list::CommunityList::default(), Default::default(), Default::default()),
4269 Err(e) => return Err(e),
4270 };
4271 doc.tombstones.retain(|t| t.community_id != cid_hex);
4272 doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at, extra: Default::default() });
4273 publish_fragments(transport, &doc, &prev_created, &read_frags, &write_relays).await.map(|_| ())
4277}
4278
4279pub fn tombstone_community_list_durable(community_id: crate::community::CommunityId, relays: Vec<String>, removed_at: u64) {
4287 if crate::state::nostr_client().is_none() {
4288 return;
4289 }
4290 crate::db::spawn_bound(async move {
4291 for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
4292 crate::rt::time::sleep(std::time::Duration::from_secs(*wait)).await;
4293 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4294 match tombstone_community_list(&transport, &community_id, &relays, removed_at).await {
4295 Ok(()) => {
4296 crate::log_net_info!(
4297 "[CommunityList] leave tombstone for {} landed on retry #{}",
4298 &crate::simd::hex::bytes_to_hex_32(&community_id.0)[..8],
4299 attempt + 1
4300 );
4301 return;
4302 }
4303 Err(e) => crate::log_warn!("[CommunityList] leave tombstone retry #{} failed: {}", attempt + 1, e),
4304 }
4305 }
4306 crate::log_net_fail!(
4307 "[CommunityList] leave tombstone for {} NEVER landed after {} attempts — it will come back on another device",
4308 &crate::simd::hex::bytes_to_hex_32(&community_id.0)[..8],
4309 LIST_REPUBLISH_BACKOFF_SECS.len()
4310 );
4311 });
4312}
4313
4314pub struct ListSyncOutcome {
4321 pub joined: Vec<CommunityV2>,
4323 pub removed: Vec<(String, Vec<String>)>,
4330}
4331
4332const LIST_SEEDED_KEY: &str = "community_list_frag_seeded";
4334
4335pub const ERR_DISSOLVED: &str = "this community has been dissolved";
4340
4341pub async fn retire_dead_invite<T: Transport + ?Sized>(transport: &T, community_id_hex: &str, relays: &[String]) {
4345 let _ = crate::db::community::delete_pending_invite(community_id_hex);
4346 if let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(community_id_hex) {
4347 let id = crate::community::CommunityId(cid);
4348 let at = now_ms();
4349 if tombstone_community_list(transport, &id, relays, at).await.is_err() {
4350 tombstone_community_list_durable(id, relays.to_vec(), at);
4353 }
4354 }
4355 crate::emit_event("community_invites_purged", &serde_json::json!({}));
4356}
4357
4358async fn confirmed_no_fragments<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> bool {
4363 let Ok(my_pk) = me_pk() else { return false };
4364 if relays.is_empty() {
4365 return false;
4366 }
4367 let query = Query {
4368 kinds: vec![super::kind::COMMUNITY_LIST_FRAG],
4369 authors: vec![my_pk.to_hex()],
4370 limit: Some(1),
4371 ..Default::default()
4372 };
4373 for relay in relays {
4374 match transport.fetch(&query, std::slice::from_ref(relay)).await {
4375 Ok(events) if events.is_empty() => {}
4376 _ => return false,
4379 }
4380 }
4381 true
4382}
4383
4384async fn seed_community_list<T: Transport + ?Sized>(transport: &T) {
4390 if crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).ok().flatten().is_some() {
4391 return;
4392 }
4393 if held_v2_relays().is_empty() {
4394 return;
4395 }
4396 let own = own_list_relays().await;
4399 let confirm = if own.is_empty() { held_v2_relays() } else { own };
4400 if !confirmed_no_fragments(transport, &confirm).await {
4401 crate::log_warn!("[CommunityList] seed DEFERRED — the empty read is unconfirmed; retrying on a later boot");
4402 return;
4403 }
4404 crate::log_net_info!("[CommunityList] no fragments held anywhere (confirmed per relay) — seeding from local state");
4405 match republish_community_list(transport, None).await {
4406 Ok(true) => {
4407 let _ = crate::db::settings::set_sql_setting(LIST_SEEDED_KEY.to_string(), "1".to_string());
4408 }
4409 Ok(false) => {}
4410 Err(e) => crate::log_net_fail!("[CommunityList] seed failed: {e}"),
4411 }
4412}
4413
4414pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
4415 crate::db::scoped(async move {
4416 let relays = list_read_relays(bootstrap_relays).await;
4417 if relays.is_empty() {
4418 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
4419 }
4420 let list = match fetch_fragments(transport, &relays).await {
4424 Ok(Some(set)) => {
4425 crate::log_net_info!(
4426 "[CommunityList] fetched: {} of {} fragment(s), {} entries, {} tombstones, across {} relays",
4427 set.created_at.len(),
4428 set.declared,
4429 set.list.entries.len(),
4430 set.list.tombstones.len(),
4431 relays.len()
4432 );
4433 set.list
4434 }
4435 Ok(None) => {
4436 crate::log_debug!("[CommunityList] no fragments across {} relays", relays.len());
4440 seed_community_list(transport).await;
4441 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
4442 }
4443 Err(e) => {
4444 crate::log_net_fail!("[CommunityList] fetch failed across {} relays: {e}", relays.len());
4445 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
4446 }
4447 };
4448 let mut removed: Vec<(String, Vec<String>)> = Vec::new();
4454 for t in &list.tombstones {
4455 if list.is_live(&t.community_id) {
4456 continue;
4457 }
4458 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
4459 let id = crate::community::CommunityId(cid);
4460 let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
4461 continue; };
4463 if held.created_at_ms > t.removed_at {
4469 crate::log_warn!(
4470 "[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
4471 &t.community_id[..8], t.removed_at, held.created_at_ms
4472 );
4473 continue;
4474 }
4475 let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
4476 let _ = crate::db::community::delete_community(&t.community_id);
4477 removed.push((t.community_id.clone(), channel_ids));
4478 }
4479 let mut invites_purged = 0usize;
4485 for t in &list.tombstones {
4486 if list.is_live(&t.community_id) {
4487 continue;
4488 }
4489 let Ok(Some(received_at)) = crate::db::community::pending_invite_received_at(&t.community_id) else { continue };
4490 if (received_at.max(0) as u64).saturating_mul(1000) <= t.removed_at
4491 && crate::db::community::delete_pending_invite(&t.community_id).is_ok()
4492 {
4493 invites_purged += 1;
4494 }
4495 }
4496 if invites_purged > 0 {
4497 crate::emit_event("community_invites_purged", &serde_json::json!({}));
4498 }
4499 let mut joined = Vec::new();
4500 for entry in list.live_entries() {
4501 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
4502 if crate::db::community::community_exists(&crate::community::CommunityId(cid)).unwrap_or(false) {
4507 continue; }
4509 let bundle = material_to_invite(&entry.current);
4514 match accept_bundle(transport, &bundle, None, false).await {
4515 Ok(community) => {
4516 if let (Some(pk), Some(root_hex)) = (community.control_pk, entry.current.control_root.as_deref()) {
4522 if let Some(root) = crate::simd::hex::hex_to_bytes_32_checked(root_hex) {
4523 if super::derive::control_signer_group_key(&root, community.id(), community.root_epoch).pk() == pk
4524
4525 {
4526 let mut held = community.clone();
4527 held.control_root = Some(root);
4528 let _ = crate::db::community::save_community_v2(&held);
4529 }
4530 }
4531 }
4532 joined.push(community);
4533 }
4534 Err(e) => crate::log_net_fail!(
4538 "[CommunityList] {} is listed but adoption failed: {e}",
4539 &entry.community_id[..entry.community_id.len().min(8)]
4540 ),
4541 }
4542 }
4543 Ok(ListSyncOutcome { joined, removed })
4544 })
4545 .await
4546}
4547
4548pub(super) fn citation_is_synced(
4578 cid_hex: &str,
4579 owner_hex: &str,
4580 actor_hex: &str,
4581 citation: Option<&crate::community::edition::AuthorityCitation>,
4582) -> bool {
4583 if owner_hex == actor_hex {
4584 return true;
4585 }
4586 if citation.is_none() {
4587 return false;
4588 }
4589 let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
4590 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
4591 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
4592 &crate::community::CommunityId(cid_bytes),
4593 &actor_bytes,
4594 ));
4595 let head: Vec<crate::community::roster::EntityHead> =
4596 crate::db::community::get_edition_head(cid_hex, &grant_hex)
4597 .ok()
4598 .flatten()
4599 .map(|(version, self_hash)| crate::community::roster::EntityHead {
4600 entity_hex: grant_hex.clone(),
4601 version,
4602 self_hash,
4603 inner_id: [0u8; 32],
4604 citation: None,
4605 })
4606 .into_iter()
4607 .collect();
4608 crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
4609}
4610
4611fn required_authority_citation(
4619 community: &CommunityV2,
4620 actor: &PublicKey,
4621) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
4622 if community.owner().ok().as_ref() == Some(actor) {
4623 return Ok(None); }
4625 my_authority_citation(community, actor).map(Some).ok_or_else(|| {
4626 "your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
4627 })
4628}
4629
4630fn my_authority_citation(
4631 community: &CommunityV2,
4632 actor: &PublicKey,
4633) -> Option<crate::community::edition::AuthorityCitation> {
4634 if community.owner().ok().as_ref() == Some(actor) {
4635 return None;
4636 }
4637 let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
4638 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4639 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
4640 crate::db::community::get_edition_head(&cid_hex, &entity_hex)
4641 .ok()
4642 .flatten()
4643 .map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
4644}
4645
4646fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
4654 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4655 match crate::db::community::get_server_root_epoch(&cid_hex)? {
4656 Some(held) if held != community.root_epoch.0 => Err(format!(
4657 "the community re-founded mid-action (epoch {} -> {held}); retry",
4658 community.root_epoch.0
4659 )),
4660 _ => Ok(()), }
4662}
4663
4664async fn publish_control_edition<T: Transport + ?Sized>(
4665 transport: &T,
4666 community: &CommunityV2,
4667 vsk: &str,
4668 entity_id: &[u8; 32],
4669 content: &str,
4670) -> Result<(), String> {
4671 crate::db::scoped(async move {
4672 assert_current_root(community)?;
4673 let signer = crate::signer::active_signer()?;
4674 let my_pk = me_pk()?;
4675 let view = control::ControlPlane::of(community);
4679 let control = view.write_group()?;
4680 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4681 let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
4682 let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
4683 Some((v, h)) => (v + 1, Some(h)),
4684 None => (1, None),
4685 };
4686 let citation = required_authority_citation(community, &my_pk)?;
4692 let at = now_ms() / 1000;
4693 let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
4694 let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
4695 transport.publish(&wrap, &community.relays).await?;
4696 if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
4702 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
4703 }
4704 Ok(())
4705 })
4706 .await
4707}
4708
4709fn merge_local_roster(cid_hex: &str, role: Option<&crate::community::roles::Role>, grant: Option<&crate::community::roles::MemberGrant>) {
4718 let mut roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
4719 if let Some(r) = role {
4720 match roster.roles.iter_mut().find(|x| x.role_id == r.role_id) {
4721 Some(slot) => *slot = r.clone(),
4722 None => roster.roles.push(r.clone()),
4723 }
4724 }
4725 if let Some(g) = grant {
4726 match roster.grants.iter_mut().find(|x| x.member == g.member) {
4727 Some(slot) => *slot = g.clone(),
4728 None => roster.grants.push(g.clone()),
4729 }
4730 }
4731 let at = crate::db::community::get_community_roles_at(cid_hex).unwrap_or(0);
4732 if let Err(e) = crate::db::community::set_community_roles(cid_hex, &roster, at) {
4733 crate::log_warn!("v2: local roster merge failed (heals on the next control fold): {e}");
4734 }
4735}
4736
4737pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
4741 super::roles::validate_role(role)?;
4742 let content = super::roles::role_content_json(role)?;
4743 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
4744 publish_control_edition(transport, community, vsk::ROLE, &role_id, &content).await
4745}
4746
4747pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
4753 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4754 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4755 let roster = if community.control_pk.is_some() && role_ids.iter().any(|rid| roster.role(rid).is_none()) {
4763 let mut fetched = fetch_authority(transport, community).await.roles;
4764 for r in roster.roles {
4765 if !fetched.roles.iter().any(|x| x.role_id == r.role_id) {
4766 fetched.roles.push(r);
4767 }
4768 }
4769 fetched
4770 } else {
4771 roster
4772 };
4773 grant_roles_with_roster(transport, community, member, role_ids, &roster).await
4774}
4775
4776async fn control_wrap_owed(community: &CommunityV2, makes_staff: bool, member: &PublicKey) -> Result<Option<String>, String> {
4791 if !makes_staff {
4792 return Ok(None);
4793 }
4794 let Some(pk) = community.control_pk else {
4795 return Ok(None); };
4797 let Some(cr) = community.control_root else {
4798 return Err("you don't hold this community's staff write key, so you can't promote to staff yet".to_string());
4799 };
4800 if super::derive::control_signer_group_key(&cr, community.id(), community.root_epoch).pk() != pk {
4803 return Err("this community's held staff write key is corrupt; re-sync before promoting".to_string());
4804 }
4805 use nostr_sdk::prelude::AsyncNip44;
4806 let signer = crate::signer::active_signer()?;
4807 let wrap = signer
4808 .nip44_encrypt_async(member, &super::rekey::control_wrap_b64(community.root_epoch, &cr))
4809 .await
4810 .map_err(|e| format!("staff key delivery: {e}"))?;
4811 Ok(Some(wrap))
4812}
4813
4814async fn grant_roles_with_roster<T: Transport + ?Sized>(
4818 transport: &T,
4819 community: &CommunityV2,
4820 member: &PublicKey,
4821 role_ids: Vec<String>,
4822 roster: &crate::community::roles::CommunityRoles,
4823) -> Result<(), String> {
4824 let control_wrap = control_wrap_owed(community, roster.roles_make_staff(&role_ids), member).await?;
4828 let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
4829 let content = super::roles::grant_content_json_with_wrap(&grant, control_wrap)?;
4830 let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
4831 publish_control_edition(transport, community, vsk::GRANT, &eid, &content).await
4832}
4833
4834pub async fn ensure_admin_role<T: Transport + ?Sized>(
4839 transport: &T,
4840 community: &CommunityV2,
4841 view: &AuthorityView,
4842 create_if_missing: bool,
4843) -> Result<Option<String>, String> {
4844 use crate::community::roles::{Permissions, Role, RoleScope};
4845 if let Some(r) = view
4849 .roles
4850 .roles
4851 .iter()
4852 .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK))
4853 {
4854 return Ok(Some(r.role_id.clone()));
4855 }
4856 if !create_if_missing {
4857 return Ok(None);
4858 }
4859 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4860 let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
4861 set_role(transport, community, &Role::admin(role_id.clone())).await?;
4862 Ok(Some(role_id))
4863}
4864
4865pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
4872 crate::db::scoped(async move {
4873 let my_pk = me_pk()?;
4876 if my_pk != community.owner()? {
4877 return Err("only the community owner can grant @admin".to_string());
4878 }
4879 let view = fetch_authority(transport, community).await;
4880 let member_hex = member.to_hex();
4881 require_grant_head(community, &view, &member_hex)?;
4882 let role_id = ensure_admin_role(transport, community, &view, true)
4883 .await?
4884 .expect("create_if_missing yields an id");
4885 let mut role_ids = view
4886 .roles
4887 .grants
4888 .iter()
4889 .find(|g| g.member == member_hex)
4890 .map(|g| g.role_ids.clone())
4891 .unwrap_or_default();
4892 if role_ids.contains(&role_id) {
4893 return Ok(()); }
4895 role_ids.push(role_id.clone());
4896 let mut roster = view.roles.clone();
4900 if roster.role(&role_id).is_none() {
4901 roster.roles.push(crate::community::roles::Role::admin(role_id));
4902 }
4903 grant_roles_with_roster(transport, community, member, role_ids, &roster).await
4904 })
4905 .await
4906}
4907
4908pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
4911 crate::db::scoped(async move {
4912 let my_pk = me_pk()?;
4913 if my_pk != community.owner()? {
4914 return Err("only the community owner can revoke @admin".to_string());
4915 }
4916 let view = fetch_authority(transport, community).await;
4917 let member_hex = member.to_hex();
4918 require_grant_head(community, &view, &member_hex)?;
4919 let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
4920 return Ok(()); };
4922 let mut role_ids = view
4923 .roles
4924 .grants
4925 .iter()
4926 .find(|g| g.member == member_hex)
4927 .map(|g| g.role_ids.clone())
4928 .unwrap_or_default();
4929 let before = role_ids.len();
4930 role_ids.retain(|r| r != &role_id);
4931 if role_ids.len() == before {
4932 return Ok(());
4933 }
4934 grant_roles_with_roster(transport, community, member, role_ids, &view.roles).await
4937 })
4938 .await
4939}
4940
4941struct RoleAuthor {
4950 me_hex: String,
4951 owner_hex: String,
4952 is_owner: bool,
4953 rank: u32,
4955 grantable: u64,
4957}
4958
4959impl RoleAuthor {
4960 fn outranks(&self, position: u32) -> bool {
4962 self.is_owner || self.rank < position
4963 }
4964 fn may_add(&self, from: u64, to: u64) -> bool {
4967 self.is_owner || (to & !from) & !self.grantable == 0
4968 }
4969}
4970
4971fn role_author(community: &CommunityV2, view: &AuthorityView) -> Result<RoleAuthor, String> {
4972 use crate::community::roles::Permissions;
4973 let me = me_pk()?;
4974 let owner = community.owner()?;
4975 let (me_hex, owner_hex) = (me.to_hex(), owner.to_hex());
4976 let is_owner = me == owner;
4977 if !is_owner {
4978 if view.banned.contains(&me_hex) {
4979 return Err("you are banned from this community".to_string());
4980 }
4981 if !view.roles.is_authorized(&me_hex, Some(&owner_hex), Permissions::MANAGE_ROLES) {
4982 return Err("managing roles needs the Manage Roles permission".to_string());
4983 }
4984 }
4985 let rank = if is_owner { 0 } else { view.roles.highest_position(&me_hex).unwrap_or(u32::MAX) };
4986 let grantable = if is_owner { u64::MAX } else { view.roles.effective_permissions(&me_hex).0 };
4987 Ok(RoleAuthor { me_hex, owner_hex, is_owner, rank, grantable })
4988}
4989
4990fn require_role_head(view: &AuthorityView, role_id: &str) -> Result<(), String> {
4993 let eid = role_id.to_ascii_lowercase();
4994 if view.floored.contains(&eid) && !view.head_entities.contains(&eid) {
4995 return Err("this role's current state could not be fetched; try again once relays serve the control plane".to_string());
4996 }
4997 Ok(())
4998}
4999
5000pub async fn create_role<T: Transport + ?Sized>(
5004 transport: &T,
5005 community: &CommunityV2,
5006 name: &str,
5007 color: u32,
5008 permissions: u64,
5009 scope: crate::community::roles::RoleScope,
5010) -> Result<String, String> {
5011 crate::db::scoped(async move {
5012 use crate::community::roles::{Permissions, Role};
5013 let view = fetch_authority(transport, community).await;
5014 let author = role_author(community, &view)?;
5015 if view.roles.roles.len() >= super::roles::MAX_ROLES_PER_COMMUNITY {
5016 return Err(format!("a community holds at most {} roles", super::roles::MAX_ROLES_PER_COMMUNITY));
5017 }
5018 if !author.may_add(0, permissions) {
5019 return Err("you can't give a role permissions you don't hold".to_string());
5020 }
5021 let name = name.trim();
5022 if name.is_empty() {
5023 return Err("a role needs a name".to_string());
5024 }
5025 let floor = view
5029 .roles
5030 .roles
5031 .iter()
5032 .map(|r| r.position)
5033 .filter(|p| *p < ACCESS_ROLE_POSITION)
5034 .max()
5035 .unwrap_or(0)
5036 .max(author.rank);
5037 if floor.saturating_add(1) >= ACCESS_ROLE_POSITION {
5038 return Err("there's no room beneath your role for a new one; reorder the roles first".to_string());
5039 }
5040 let role = Role {
5041 role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
5042 name: name.to_string(),
5043 position: floor.saturating_add(1),
5044 permissions: Permissions(permissions),
5045 scope,
5046 color,
5047 extra: Default::default(),
5048 };
5049 set_role(transport, community, &role).await?;
5050 Ok(role.role_id)
5051 })
5052 .await
5053}
5054
5055pub async fn edit_role<T: Transport + ?Sized>(
5059 transport: &T,
5060 community: &CommunityV2,
5061 role_id: &str,
5062 name: &str,
5063 color: u32,
5064 permissions: u64,
5065 scope: crate::community::roles::RoleScope,
5066) -> Result<(), String> {
5067 crate::db::scoped(async move {
5068 use crate::community::roles::Permissions;
5069 let view = fetch_authority(transport, community).await;
5070 let author = role_author(community, &view)?;
5071 let current = view.roles.role(role_id).cloned().ok_or("no such role")?;
5072 require_role_head(&view, role_id)?;
5073 if !author.outranks(current.position) {
5074 return Err("you can only edit roles beneath your own".to_string());
5075 }
5076 if !author.may_add(current.permissions.0, permissions) {
5077 return Err("you can't give a role permissions you don't hold".to_string());
5078 }
5079 let name = name.trim();
5080 if name.is_empty() {
5081 return Err("a role needs a name".to_string());
5082 }
5083 if scope != current.scope {
5086 return Err("a role's channel can't be changed once it's created".to_string());
5087 }
5088 let mut next = current.clone();
5089 next.name = name.to_string();
5090 next.color = color;
5091 next.permissions = Permissions(permissions);
5092 if next == current {
5093 return Ok(());
5094 }
5095 set_role(transport, community, &next).await
5096 })
5097 .await
5098}
5099
5100pub async fn reorder_roles<T: Transport + ?Sized>(
5108 transport: &T,
5109 community: &CommunityV2,
5110 ordered: &[String],
5111) -> Result<usize, String> {
5112 crate::db::scoped(async move {
5113 let view = fetch_authority(transport, community).await;
5114 let author = role_author(community, &view)?;
5115 let mut movable: Vec<crate::community::roles::Role> = view
5116 .roles
5117 .roles
5118 .iter()
5119 .filter(|r| author.outranks(r.position))
5120 .cloned()
5121 .collect();
5122 movable.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.role_id.cmp(&b.role_id)));
5124 let rank_of = |id: &str| ordered.iter().position(|o| o.eq_ignore_ascii_case(id));
5125 let mut keyed: Vec<(usize, usize, crate::community::roles::Role)> = movable
5126 .into_iter()
5127 .enumerate()
5128 .map(|(i, r)| (rank_of(&r.role_id).unwrap_or(usize::MAX), i, r))
5129 .collect();
5130 keyed.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
5131 let mut changed = Vec::new();
5132 for (i, (_, _, role)) in keyed.into_iter().enumerate() {
5133 let position = author.rank.saturating_add(1).saturating_add(i as u32);
5134 if role.position != position {
5135 require_role_head(&view, &role.role_id)?;
5136 let mut next = role;
5137 next.position = position;
5138 changed.push(next);
5139 }
5140 }
5141 for role in &changed {
5142 set_role(transport, community, role).await?;
5143 }
5144 Ok(changed.len())
5145 })
5146 .await
5147}
5148
5149pub async fn set_member_roles<T: Transport + ?Sized>(
5154 transport: &T,
5155 community: &CommunityV2,
5156 member: &PublicKey,
5157 role_ids: Vec<String>,
5158) -> Result<(), String> {
5159 crate::db::scoped(async move {
5160 use crate::community::roles::Permissions;
5161 let view = fetch_authority(transport, community).await;
5162 let author = role_author(community, &view)?;
5163 let member_hex = member.to_hex();
5164 if member_hex == author.owner_hex {
5165 return Err("the owner's standing isn't granted by roles".to_string());
5166 }
5167 require_grant_head(community, &view, &member_hex)?;
5168 if !author.is_owner && !view.roles.can_act_on_member(&author.me_hex, Some(&author.owner_hex), &member_hex, Permissions::MANAGE_ROLES) {
5169 return Err("you can only change the roles of members beneath you".to_string());
5170 }
5171 let current: Vec<String> = view
5172 .roles
5173 .grants
5174 .iter()
5175 .find(|g| g.member == member_hex)
5176 .map(|g| g.role_ids.clone())
5177 .unwrap_or_default();
5178 let mut next: Vec<String> = Vec::new();
5179 for id in role_ids.iter().map(|r| r.to_ascii_lowercase()) {
5180 if !next.contains(&id) {
5181 next.push(id);
5182 }
5183 }
5184 for id in next.iter().filter(|id| !current.contains(id)) {
5185 let role = view.roles.role(id).ok_or("no such role")?;
5186 if !author.outranks(role.position) {
5187 return Err(format!("you can't grant \"{}\": it isn't beneath your own role", role.name));
5188 }
5189 }
5190 let dropped: Vec<String> = current.iter().filter(|id| !next.contains(id)).cloned().collect();
5191 for id in dropped {
5192 match view.roles.role(&id) {
5194 Some(role) if !author.outranks(role.position) => {
5195 return Err(format!("you can't remove \"{}\": it isn't beneath your own role", role.name));
5196 }
5197 Some(_) => {}
5198 None => next.push(id),
5199 }
5200 }
5201 if next.len() > super::roles::MAX_ROLES_PER_MEMBER {
5202 return Err(format!("a member holds at most {} roles", super::roles::MAX_ROLES_PER_MEMBER));
5203 }
5204 let mut a = current.clone();
5205 let mut b = next.clone();
5206 a.sort();
5207 b.sort();
5208 if a == b {
5209 return Ok(());
5210 }
5211 grant_roles_with_roster(transport, community, member, next.clone(), &view.roles).await?;
5212 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5213 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member_hex.clone(), role_ids: next.clone() }));
5214 settle_channel_keys(transport, community, &view, member, ¤t, &next).await
5215 })
5216 .await
5217}
5218
5219async fn settle_channel_keys<T: Transport + ?Sized>(
5225 transport: &T,
5226 community: &CommunityV2,
5227 view: &AuthorityView,
5228 member: &PublicKey,
5229 before: &[String],
5230 after: &[String],
5231) -> Result<(), String> {
5232 use crate::community::roles::RoleScope;
5233 let added: Vec<String> = after.iter().filter(|id| !before.contains(id)).cloned().collect();
5234 let removed: Vec<String> = before.iter().filter(|id| !after.contains(id)).cloned().collect();
5235 let owner_hex = community.owner()?.to_hex();
5236 let member_hex = member.to_hex();
5237 let channel_of = |id: &String| match view.roles.role(id).map(|r| &r.scope) {
5238 Some(RoleScope::Channel(c)) => Some(c.to_ascii_lowercase()),
5239 _ => None,
5240 };
5241 let held = |hex: &str| {
5242 crate::simd::hex::hex_to_bytes_32_checked(hex)
5243 .and_then(|b| community.channel(&ChannelId(b)).map(|c| (c.private, c.key.is_some(), ChannelId(b))))
5244 };
5245
5246 if added.iter().filter_map(channel_of).any(|c| matches!(held(&c), Some((true, true, _)))) {
5248 let my_pk = me_pk()?;
5249 let bundle = bundle_of_with_overlay(community, BundleAudience::Member(*member), Some(my_pk), None, None, &added, &removed);
5250 let signer = crate::signer::active_signer()?;
5251 let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
5252 transport.publish(&wrap, &community.relays).await?;
5253 }
5254
5255 let mut severed: Vec<String> = Vec::new();
5256 for chan in removed.iter().filter_map(channel_of) {
5257 if severed.contains(&chan) {
5258 continue;
5259 }
5260 severed.push(chan.clone());
5261 let Some((true, true, channel_id)) = held(&chan) else { continue };
5262 if view.roles.is_entitled(Some(&owner_hex), &member_hex, &chan, &added, &removed) {
5263 continue; }
5265 let access_ids = view.roles.channel_role_ids(&chan);
5266 rekey_channel_excluding(transport, community, &channel_id, &view.roles, &access_ids, std::slice::from_ref(member)).await?;
5267 }
5268 Ok(())
5269}
5270
5271fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
5275 let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
5276 return Err("malformed member key".to_string());
5277 };
5278 let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
5279 if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
5280 return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
5281 }
5282 Ok(())
5283}
5284
5285pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
5292 crate::db::scoped(async move {
5293 super::roles::validate_banlist(banned)?;
5294 {
5295 let my_pk = me_pk()?;
5296 let owner = community.owner()?;
5297 if my_pk != owner {
5298 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5299 let me_hex = my_pk.to_hex();
5300 let current = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5301 if current.contains(&me_hex) {
5302 return Err("you are banned from this community".to_string());
5303 }
5304 let roster = crate::db::community::get_community_roles(&cid_hex)?;
5305 let owner_hex = owner.to_hex();
5306 if !roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN) {
5307 return Err("editing the banlist needs the BAN permission".to_string());
5308 }
5309 for target in banned.iter().filter(|t| !current.contains(*t)) {
5312 if !roster.can_act_on_member(&me_hex, Some(&owner_hex), target, crate::community::roles::Permissions::BAN) {
5313 return Err("you do not outrank a member this ban targets".to_string());
5314 }
5315 }
5316 }
5317 }
5318 let content = super::roles::banlist_content_json(banned)?;
5319 let eid = super::derive::banlist_locator(community.id());
5320 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5321 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
5322 let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
5324 Some((v, _)) => v + 1,
5325 None => 1,
5326 };
5327 publish_control_edition(transport, community, vsk::BANLIST, &eid, &content).await?;
5328 let _ = crate::db::community::set_community_banlist(&cid_hex, banned, version as i64);
5335 Ok(())
5336 })
5337 .await
5338}
5339
5340struct ModerationLocks;
5343fn moderation_lock(cid: &crate::community::CommunityId) -> std::sync::Arc<tokio::sync::Mutex<()>> {
5344 let map = crate::db::current_session()
5345 .scoped::<ModerationLocks, std::sync::Mutex<std::collections::HashMap<[u8; 32], std::sync::Arc<tokio::sync::Mutex<()>>>>>();
5346 let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
5347 m.entry(cid.0).or_default().clone()
5348}
5349
5350async fn converge_authority<T: Transport + ?Sized>(transport: &T, id: &crate::community::CommunityId) {
5354 if let Ok(Some(fresh)) = crate::db::community::load_community_v2(id) {
5355 let _ = follow_control(transport, &fresh).await;
5356 if let Ok(added) = sync_guestbook(transport, &fresh).await {
5360 if !added.is_empty() {
5361 crate::traits::emit_event_json(
5362 "community_refreshed",
5363 serde_json::json!({ "community_id": crate::simd::hex::bytes_to_hex_32(&id.0) }),
5364 );
5365 }
5366 }
5367 }
5368}
5369
5370struct ModIntent {
5372 id: u64,
5373 targets: Vec<PublicKey>,
5374 banned: bool,
5375 done: tokio::sync::oneshot::Sender<Result<(), String>>,
5376}
5377
5378struct PendingModeration;
5381fn pending_moderation(cid: &crate::community::CommunityId) -> std::sync::Arc<std::sync::Mutex<std::collections::HashMap<[u8; 32], Vec<ModIntent>>>> {
5382 let _ = cid;
5383 crate::db::current_session()
5384 .scoped::<PendingModeration, std::sync::Mutex<std::collections::HashMap<[u8; 32], Vec<ModIntent>>>>()
5385}
5386
5387pub async fn set_members_banned<T: Transport + ?Sized>(
5408 transport: &T,
5409 community_id: &crate::community::CommunityId,
5410 members: &[PublicKey],
5411 banned: bool,
5412) -> Result<(), String> {
5413 crate::db::scoped(async move {
5414 let mut targets: Vec<PublicKey> = Vec::with_capacity(members.len());
5416 for m in members {
5417 if !targets.contains(m) {
5418 targets.push(*m);
5419 }
5420 }
5421 if targets.is_empty() {
5422 return Ok(());
5423 }
5424 static INTENT_IDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
5426 let my_id = INTENT_IDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5427 let (tx, rx) = tokio::sync::oneshot::channel();
5428 {
5429 let map = pending_moderation(community_id);
5430 let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
5431 m.entry(community_id.0).or_default().push(ModIntent { id: my_id, targets, banned, done: tx });
5432 }
5433 let unit = moderation_lock(community_id);
5434 tokio::select! {
5437 biased;
5438 res = rx => res.unwrap_or_else(|_| Err("moderation unit aborted before completing".to_string())),
5439 _guard = unit.lock() => {
5440 let batch: Vec<ModIntent> = {
5443 let map = pending_moderation(community_id);
5444 let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
5445 m.remove(&community_id.0).unwrap_or_default()
5446 };
5447 run_moderation_batch(transport, community_id, batch, my_id).await
5448 }
5449 }
5450 })
5451 .await
5452}
5453
5454async fn run_moderation_batch<T: Transport + ?Sized>(
5459 transport: &T,
5460 community_id: &crate::community::CommunityId,
5461 batch: Vec<ModIntent>,
5462 leader_id: u64,
5463) -> Result<(), String> {
5464 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
5465
5466 converge_authority(transport, community_id).await;
5471
5472 let original = crate::db::community::get_community_banlist(&cid_hex)?;
5473 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5474 let community0 = crate::db::community::load_community_v2(community_id)?
5475 .ok_or_else(|| "v2 community not found".to_string())?;
5476 let owner = community0.owner()?;
5477 let my_pk = me_pk()?;
5478 let my_hex = my_pk.to_hex();
5479 let owner_hex = owner.to_hex();
5480
5481 let mut list = original.clone();
5484 let mut pk_of: std::collections::HashMap<String, PublicKey> = std::collections::HashMap::new();
5485 let mut verdicts: std::collections::HashMap<u64, Result<(), String>> = std::collections::HashMap::new();
5486 for intent in &batch {
5487 let hexes: Vec<String> = intent.targets.iter().map(|p| p.to_hex()).collect();
5488 for (pk, hex) in intent.targets.iter().zip(&hexes) {
5489 pk_of.insert(hex.clone(), *pk);
5490 }
5491 let verdict = (|| {
5492 if my_pk != owner {
5495 if original.contains(&my_hex) {
5496 return Err("you are banned from this community".to_string());
5497 }
5498 if !roster.is_authorized(&my_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN) {
5499 return Err("editing the banlist needs the BAN permission".to_string());
5500 }
5501 if intent.banned {
5502 for t in hexes.iter().filter(|t| !list.contains(*t)) {
5503 if !roster.can_act_on_member(&my_hex, Some(&owner_hex), t, crate::community::roles::Permissions::BAN) {
5504 return Err("you do not outrank a member this ban targets".to_string());
5505 }
5506 }
5507 }
5508 }
5509 let mut next = list.clone();
5510 next.retain(|h| !hexes.contains(h));
5511 if intent.banned {
5512 next.extend(hexes.iter().cloned());
5513 }
5514 super::roles::validate_banlist(&next)?;
5515 list = next;
5516 Ok(())
5517 })();
5518 verdicts.insert(intent.id, verdict);
5519 }
5520
5521 let changed = {
5522 let mut a = original.clone();
5523 let mut b = list.clone();
5524 a.sort();
5525 b.sort();
5526 a != b
5527 };
5528 let newly_banned: Vec<PublicKey> = list
5529 .iter()
5530 .filter(|h| !original.contains(*h))
5531 .filter_map(|h| pk_of.get(h).copied())
5532 .collect();
5533
5534 let unit_result: Result<(), String> = async {
5536 if !changed {
5537 return Ok(());
5538 }
5539 let (community, stripped) = {
5543 let lock = super::realtime::follow_lock(community_id);
5544 let _rotation = lock.lock().await;
5545 let community = crate::db::community::load_community_v2(community_id)?
5546 .ok_or_else(|| "v2 community not found".to_string())?;
5547 set_banlist(transport, &community, &list).await?;
5548 let mut stripped: Vec<(PublicKey, Vec<String>)> = Vec::new();
5549 for pk in &newly_banned {
5553 let hex = pk.to_hex();
5554 let roles: Vec<String> = roster.roles_of(&hex).map(|r| r.role_id.clone()).collect();
5555 let has_grant = roster.grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
5558 if has_grant {
5559 let can_strip = my_pk == owner
5565 || roster.can_act_on_member(&my_hex, Some(&owner_hex), &hex, crate::community::roles::Permissions::MANAGE_ROLES);
5566 if can_strip {
5567 grant_roles(transport, &community, pk, vec![]).await?;
5568 } else {
5569 crate::log_warn!("[Ban] grant strip skipped (no MANAGE_ROLES over the target); the banlist still silences");
5570 }
5571 }
5572 stripped.push((*pk, roles));
5573 }
5574 (community, stripped)
5575 };
5576 if !newly_banned.is_empty() {
5577 if community_is_public(transport, &community).await {
5583 crate::log_info!("[Ban] public community — banlist + grant strip, no refound (CORD-05 §5)");
5584 match sever_banned_private_reads(transport, &community, &stripped).await {
5587 Ok(0) => {}
5588 Ok(n) => crate::log_info!("[Ban] rotated {n} private channel(s) the banned member(s) could read"),
5589 Err(e) => crate::log_warn!("[Ban] private-channel read severance incomplete: {e}"),
5590 }
5591 } else if let Err(e) = refound_community(transport, &community, &newly_banned).await {
5592 crate::log_warn!("[Ban] refound deferred (banlist + grant strip landed): {e}");
5601 }
5602 }
5603 Ok(())
5604 }
5605 .await;
5606 converge_authority(transport, community_id).await;
5607
5608 let mut my_verdict = Err("leader intent missing from its own batch".to_string());
5610 for intent in batch {
5611 let final_verdict = verdicts
5612 .remove(&intent.id)
5613 .unwrap_or_else(|| Err("intent was never judged".to_string()))
5614 .and_then(|()| unit_result.clone());
5615 if intent.id == leader_id {
5616 my_verdict = final_verdict;
5617 } else {
5618 let _ = intent.done.send(final_verdict);
5619 }
5620 }
5621 my_verdict
5622}
5623
5624#[derive(Debug, Clone, Default, serde::Serialize)]
5630pub struct ContainmentReport {
5631 pub banned: usize,
5633 pub window_cut: usize,
5635 pub epoch_before: u64,
5637 pub epoch_after: u64,
5638 pub refound_ok: bool,
5640 pub own_links_revoked: usize,
5643 pub foreign_link_creators: Vec<String>,
5645 pub warnings: Vec<String>,
5647}
5648
5649pub async fn contain_raid<T: Transport + ?Sized>(
5663 transport: &T,
5664 community_id: &crate::community::CommunityId,
5665 raiders: &[PublicKey],
5666 window_start_secs: u64,
5667 cut_also: &[PublicKey],
5668) -> Result<ContainmentReport, String> {
5669 crate::db::scoped(async move {
5670 let mut report = ContainmentReport::default();
5671 let community = crate::db::community::load_community_v2(community_id)?
5672 .ok_or_else(|| "v2 community not found".to_string())?;
5673 report.epoch_before = community.root_epoch.0;
5674 report.epoch_after = community.root_epoch.0;
5675 let owner = community.owner()?;
5676 let my_pk = me_pk()?;
5677
5678 if !raiders.is_empty() {
5685 match set_members_banned(transport, community_id, raiders, true).await {
5686 Ok(()) => report.banned = raiders.len(),
5687 Err(e) => report.warnings.push(format!("banlist did not land ({e}) — the refound still cuts every named raider")),
5688 }
5689 }
5690
5691 let community = crate::db::community::load_community_v2(community_id)?
5693 .ok_or_else(|| "v2 community not found".to_string())?;
5694
5695 let mut cut: Vec<PublicKey> = raiders.to_vec();
5705 for pk in cut_also {
5706 if !cut.contains(pk) {
5707 cut.push(*pk);
5708 report.window_cut += 1;
5709 }
5710 }
5711 if window_start_secs > 0 {
5712 let roster = crate::db::community::get_community_roles(
5713 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
5714 )
5715 .unwrap_or_default();
5716 let owner_hex = owner.to_hex();
5717 match fetch_guestbook_events(transport, &community, 0).await {
5718 Ok((events, _, _)) => {
5719 let mut first_seen: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
5722 for ev in &events {
5723 if let guestbook::GuestbookEntry::Join { member, at_ms, .. } = &ev.entry {
5724 let secs = at_ms / 1000;
5725 first_seen
5726 .entry(*member)
5727 .and_modify(|e| *e = (*e).min(secs))
5728 .or_insert(secs);
5729 }
5730 }
5731 for (pk, joined) in first_seen {
5732 if joined < window_start_secs || pk == owner || pk == my_pk || cut.contains(&pk) {
5733 continue;
5734 }
5735 if roster.is_staff(&pk.to_hex(), Some(&owner_hex)) {
5738 continue;
5739 }
5740 cut.push(pk);
5741 report.window_cut += 1;
5742 }
5743 }
5744 Err(e) => report
5745 .warnings
5746 .push(format!("guestbook unreadable — no window cut applied, only convicted raiders are severed ({e})")),
5747 }
5748 }
5749
5750 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5762 let links_before = crate::db::community::list_public_invites(&cid_hex).map(|l| l.len()).unwrap_or(0);
5763 let unit = moderation_lock(community_id);
5764 let _guard = unit.lock().await;
5765 let mut refound = Err("severing refound never attempted".to_string());
5766 for attempt in 0..3u8 {
5767 refound = refound_community_severing(transport, &community, &cut, window_start_secs).await;
5768 if refound.is_ok() {
5769 break;
5770 }
5771 if attempt < 2 {
5772 crate::rt::time::sleep(std::time::Duration::from_millis(400 * (attempt as u64 + 1))).await;
5773 }
5774 }
5775 match refound {
5776 Ok(updated) => {
5777 report.refound_ok = true;
5778 report.epoch_after = updated.root_epoch.0;
5779 let links_after = crate::db::community::list_public_invites(&cid_hex).map(|l| l.len()).unwrap_or(0);
5780 report.own_links_revoked = links_before.saturating_sub(links_after);
5781 if links_after > 0 {
5782 report.warnings.push(format!(
5783 "{links_after} of my own link(s) survived the sever — the mark retries them on the next follow"
5784 ));
5785 }
5786 report.foreign_link_creators = crate::db::community::invite_link_set_creators(&cid_hex)
5787 .unwrap_or_default()
5788 .into_iter()
5789 .filter(|c| *c != my_pk.to_hex())
5790 .collect();
5791 }
5792 Err(e) => {
5793 report.warnings.push(format!("SEVERING REFOUND FAILED — the invite door may still be open: {e}"));
5796 }
5797 }
5798 Ok(report)
5799 })
5800 .await
5801}
5802
5803pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
5806 ensure_folded_permission(community, &me_pk()?, crate::community::roles::Permissions::MANAGE_METADATA, "editing the community metadata")?;
5807 control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
5808 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
5809 publish_control_edition(transport, community, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
5810}
5811
5812pub async fn persist_community_image(
5817 id: &crate::community::CommunityId,
5818 img: control::ImageRef,
5819 is_banner: bool,
5820) -> Option<CommunityV2> {
5821 let lock = super::realtime::follow_lock(id);
5822 let _guard = lock.lock().await;
5823 let mut fresh = crate::db::community::load_community_v2(id).ok()??;
5824 if is_banner {
5825 fresh.banner = Some(img);
5826 } else {
5827 fresh.icon = Some(img);
5828 }
5829 crate::db::community::save_community_v2(&fresh).ok()?;
5830 Some(fresh)
5831}
5832
5833pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
5836 let my_pk = me_pk()?;
5837 ensure_channel_manager(community, &my_pk)?;
5838 let old_name = community.channel(channel_id).map(|c| c.name.clone());
5839 if meta.private {
5844 if let Some(held) = community.channel(channel_id) {
5845 if !held.private {
5846 return Err("converting a public channel to private is not supported yet".to_string());
5847 }
5848 }
5849 }
5850 control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
5851 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
5852 publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
5853 if let Ok(Some(mut held)) = crate::db::community::load_community_v2(community.id()) {
5857 if let Some(ch) = held.channels.iter_mut().find(|c| c.id.0 == channel_id.0) {
5858 ch.name = meta.name.clone();
5859 ch.private = meta.private;
5860 ch.voice = meta.voice;
5861 ch.meta_custom = meta.custom.clone();
5862 ch.meta_extra = meta.extra.clone();
5863 crate::db::community::save_community_v2(&held)?;
5864 }
5865 }
5866 if meta.private {
5868 if let Some(old) = old_name.filter(|o| *o != meta.name) {
5869 rename_channel_access_role(transport, community, channel_id, &old, &meta.name).await;
5870 }
5871 }
5872 Ok(())
5873}
5874
5875async fn rename_channel_access_role<T: Transport + ?Sized>(
5882 transport: &T,
5883 community: &CommunityV2,
5884 channel_id: &ChannelId,
5885 old_name: &str,
5886 new_name: &str,
5887) {
5888 crate::db::scoped(async move {
5889 let (Ok(my_pk), Ok(owner)) = (me_pk(), community.owner()) else {
5890 return;
5891 };
5892 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5893 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5894 let mut roster = fetch_authority(transport, community).await.roles;
5897 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5898 for r in cached.roles {
5899 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
5900 roster.roles.push(r);
5901 }
5902 }
5903 let (me_hex, owner_hex) = (my_pk.to_hex(), owner.to_hex());
5907 if !roster.is_authorized_in(&me_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
5908 return;
5909 }
5910 let Some(mut role) = roster
5913 .channel_roles(&chan_hex)
5914 .into_iter()
5915 .find(|r| r.permissions == crate::community::roles::Permissions::empty() && r.name == old_name)
5916 .cloned()
5917 else {
5918 return;
5919 };
5920 if !roster.can_act_on_position(&me_hex, Some(&owner_hex), role.position, crate::community::roles::Permissions::MANAGE_ROLES) {
5921 return;
5922 }
5923 role.name = new_name.to_string();
5924 if let Err(e) = set_role(transport, community, &role).await {
5925 crate::log_warn!("v2: channel renamed but its access role did not follow: {e}");
5926 return;
5927 }
5928 merge_local_roster(&cid_hex, Some(&role), None);
5929 })
5930 .await
5931}
5932
5933fn ensure_folded_permission(
5940 community: &CommunityV2,
5941 me: &PublicKey,
5942 needed: u64,
5943 verb: &str,
5944) -> Result<(), String> {
5945 let owner = community.owner()?;
5946 if *me == owner {
5947 return Ok(());
5948 }
5949 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5950 let me_hex = me.to_hex();
5951 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
5952 return Err("you are banned from this community".to_string());
5953 }
5954 let roster = crate::db::community::get_community_roles(&cid_hex)?;
5955 if roster.is_authorized(&me_hex, Some(&owner.to_hex()), needed) {
5956 Ok(())
5957 } else {
5958 Err(format!("{verb} needs the {} permission", permission_label(needed)))
5959 }
5960}
5961
5962fn permission_label(bits: u64) -> &'static str {
5963 use crate::community::roles::Permissions;
5964 match bits {
5965 Permissions::MANAGE_ROLES => "MANAGE_ROLES",
5966 Permissions::MANAGE_CHANNELS => "MANAGE_CHANNELS",
5967 Permissions::MANAGE_METADATA => "MANAGE_METADATA",
5968 Permissions::BAN => "BAN",
5969 Permissions::CREATE_INVITE => "CREATE_INVITE",
5970 _ => "required",
5971 }
5972}
5973
5974fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
5976 ensure_folded_permission(community, me, crate::community::roles::Permissions::MANAGE_CHANNELS, "managing channels here")
5977}
5978
5979pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
5985 let channel_id = ChannelId(super::super::random_32());
5986 create_public_channel_with_id(transport, community, name, channel_id).await?;
5987 Ok(channel_id)
5988}
5989
5990pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
5994 crate::db::scoped(async move {
5995 let lock = super::realtime::follow_lock(community.id());
5999 let _guard = lock.lock().await;
6000 let my_pk = me_pk()?;
6001 ensure_channel_manager(community, &my_pk)?;
6002 assert_channel_id_free(&channel_id, community.id())?;
6003 let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
6004 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
6005 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
6006 publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
6007 let mut updated = community.clone();
6009 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() });
6010 crate::db::community::save_community_v2(&updated)?;
6011 Ok(())
6012 })
6013 .await
6014}
6015
6016fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
6021 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6022 if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
6023 let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
6024 let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
6025 if existing != mine
6026 && matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
6027 {
6028 return Err("channel id is already live in another v2 community".to_string());
6029 }
6030 }
6031 Ok(())
6032}
6033
6034pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
6046 let channel_id = ChannelId(super::super::random_32());
6047 create_private_channel_with_id(transport, community, name, channel_id).await?;
6048 Ok(channel_id)
6049}
6050
6051pub const ACCESS_ROLE_POSITION: u32 = u32::MAX - 1;
6058
6059pub fn channel_access_role(channel_id: &ChannelId, name: &str) -> crate::community::roles::Role {
6060 use crate::community::roles::{Permissions, Role, RoleScope};
6061 Role {
6062 role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
6063 name: name.to_string(),
6064 position: ACCESS_ROLE_POSITION,
6065 permissions: Permissions::empty(),
6066 scope: RoleScope::Channel(crate::simd::hex::bytes_to_hex_32(&channel_id.0)),
6067 color: 0, extra: Default::default(),
6068 }
6069}
6070
6071pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
6074 crate::db::scoped(async move {
6075 let lock = super::realtime::follow_lock(community.id());
6079 let _guard = lock.lock().await;
6080 let signer = crate::signer::active_signer()?;
6081 let my_pk = me_pk()?;
6082 ensure_channel_manager(community, &my_pk)?;
6083 assert_channel_id_free(&channel_id, community.id())?;
6084 let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
6085 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
6086 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
6087
6088 let channel_key = super::super::random_32();
6089 let epoch = Epoch(1);
6090
6091 let access_role = channel_access_role(&channel_id, name);
6094 let access_role_ids = vec![access_role.role_id.clone()];
6095
6096 let owner = community.owner()?;
6100 let mut recipients = vec![my_pk];
6101 if owner != my_pk {
6102 recipients.push(owner);
6103 }
6104 let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
6105 let mut blobs = Vec::with_capacity(recipients.len());
6106 for r in &recipients {
6107 blobs.push(
6108 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
6109 .await
6110 .map_err(|e| e.to_string())?,
6111 );
6112 }
6113 let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
6114 let at_secs = now_ms() / 1000;
6115 let chunks = rekey::build_rekey_chunks(&signer, my_pk, &group, RekeyScope::Channel(channel_id), epoch, Epoch(0), &prev_commit, &blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
6116 .await
6117 .map_err(|e| e.to_string())?;
6118 for c in &chunks {
6119 transport.publish_durable(c, &community.relays).await?;
6120 }
6121 publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
6122 set_role(transport, community, &access_role).await?;
6127 let mut roster_with_access = crate::db::community::get_community_roles(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap_or_default();
6131 if roster_with_access.role(&access_role.role_id).is_none() {
6132 roster_with_access.roles.push(access_role.clone());
6133 }
6134 grant_roles_with_roster(transport, community, &my_pk, access_role_ids.clone(), &roster_with_access).await?;
6135 merge_local_roster(
6138 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
6139 Some(&access_role),
6140 Some(&crate::community::roles::MemberGrant { member: my_pk.to_hex(), role_ids: access_role_ids }),
6141 );
6142 if crate::db::community::community_protocol(community.id())?.is_none() {
6144 return Err("community removed during channel create".to_string());
6145 }
6146 let mut updated = community.clone();
6147 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() });
6148 crate::db::community::save_community_v2(&updated)?;
6149 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6152 crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
6153 let _ = refresh_public_links(transport, &updated).await;
6156 Ok(())
6157 })
6158 .await
6159}
6160
6161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6165pub enum VendVerdict {
6166 Accept,
6168 Park(&'static str),
6172 Refuse(&'static str),
6174}
6175
6176pub fn judge_channel_key_vend(
6188 community: &CommunityV2,
6189 roster: &crate::community::roles::CommunityRoles,
6190 channel_id: &ChannelId,
6191 epoch: Epoch,
6192 sender_hex: &str,
6193) -> VendVerdict {
6194 let me = match me_pk() {
6195 Ok(pk) => pk.to_hex(),
6196 Err(_) => return VendVerdict::Park("no active identity"),
6197 };
6198 let owner_hex = match community.owner() {
6199 Ok(o) => o.to_hex(),
6200 Err(_) => return VendVerdict::Refuse("community has no resolvable owner"),
6201 };
6202 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6203
6204 let Some(ch) = community.channel(channel_id) else {
6207 return VendVerdict::Park("channel not in our fold yet");
6208 };
6209 if !ch.private {
6210 return VendVerdict::Refuse("vend names a channel our fold says is public");
6213 }
6214
6215 if ch.key.is_some() && epoch.0 <= ch.epoch.0 {
6223 return VendVerdict::Refuse("superseded: we already hold this epoch or newer");
6224 }
6225 if epoch.0 > ch.epoch.0.saturating_add(MAX_VEND_EPOCH_LEAD) {
6226 return VendVerdict::Refuse("vend epoch is implausibly far ahead of the channel head");
6227 }
6228
6229 if !roster.is_entitled(Some(&owner_hex), &me, &chan_hex, &[], &[]) {
6233 return VendVerdict::Park("our grant for this channel has not folded yet");
6234 }
6235
6236 if sender_hex != owner_hex && !roster.is_entitled(Some(&owner_hex), sender_hex, &chan_hex, &[], &[]) {
6239 return VendVerdict::Park("vendor's entitlement has not folded yet");
6240 }
6241
6242 VendVerdict::Accept
6243}
6244
6245const PARKED_VEND_TTL_SECS: u64 = 30 * 24 * 3600;
6248
6249const MAX_VEND_EPOCH_LEAD: u64 = 1024;
6253
6254pub fn absorb_parked_channel_keys(community: &CommunityV2, session: &std::sync::Arc<crate::db::Session>) -> Vec<ChannelId> {
6260 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6261 let parked = match crate::db::community::get_pending_channel_keys(&cid_hex) {
6262 Ok(p) if !p.is_empty() => p,
6263 _ => return Vec::new(),
6264 };
6265 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6266 let mut adopted = Vec::new();
6267 let now = now_ms() / 1000;
6268 for p in parked {
6269 if adopted.iter().any(|c: &ChannelId| crate::simd::hex::bytes_to_hex_32(&c.0) == p.channel_id) {
6273 let _ = crate::db::community::drop_pending_channel_key(p.id);
6274 continue;
6275 }
6276 if now.saturating_sub(p.received_at.max(0) as u64) > PARKED_VEND_TTL_SECS {
6282 let _ = crate::db::community::drop_pending_channel_key(p.id);
6283 continue;
6284 }
6285 let Some(id_bytes) = crate::simd::hex::hex_to_bytes_32_checked(&p.channel_id) else {
6286 let _ = crate::db::community::drop_pending_channel_key(p.id);
6287 continue;
6288 };
6289 let channel_id = ChannelId(id_bytes);
6290 match judge_channel_key_vend(community, &roster, &channel_id, Epoch(p.epoch), &p.sender) {
6291 VendVerdict::Accept => {
6292 if !session.is_live() {
6293 return adopted;
6294 }
6295 let keyless = community.channel(&channel_id).is_some_and(|c| c.key.is_none());
6300 let seated = if keyless {
6301 crate::db::community::seat_channel_key(&cid_hex, &p.channel_id, p.epoch, &p.key)
6302 } else {
6303 crate::db::community::advance_channel_epoch(&cid_hex, &p.channel_id, p.epoch, &p.key).map(|_| ())
6304 };
6305 if let Err(e) = seated {
6306 crate::log_warn!("v2: adopting a vended channel key failed: {e}");
6307 continue;
6308 }
6309 let _ = crate::db::community::drop_pending_channel_keys_for(&cid_hex, &p.channel_id);
6311 adopted.push(channel_id);
6312 }
6313 VendVerdict::Refuse(why) => {
6314 crate::log_warn!("v2: refused a vended channel key for {}: {why}", p.channel_id);
6315 let _ = crate::db::community::drop_pending_channel_key(p.id);
6317 }
6318 VendVerdict::Park(_) => {}
6320 }
6321 }
6322 adopted
6323}
6324
6325pub async fn grant_channel_access<T: Transport + ?Sized>(
6335 transport: &T,
6336 community: &CommunityV2,
6337 channel_id: &ChannelId,
6338 member: &PublicKey,
6339) -> Result<(), String> {
6340 crate::db::scoped(async move {
6341 let my_pk = me_pk()?;
6342 let ch = community.channel(channel_id).ok_or("unknown channel")?;
6343 if !ch.private {
6344 return Err("channel is public — every member already reads it".to_string());
6345 }
6346 if ch.key.is_none() {
6347 return Err("we hold no key for this channel, so we cannot vend it".to_string());
6348 }
6349 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6350 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6351 let owner_hex = community.owner()?.to_hex();
6352 let view = fetch_authority(transport, community).await;
6358 let mut roster = view.roles.clone();
6359 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6360 for r in cached.roles {
6361 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
6362 roster.roles.push(r);
6363 }
6364 }
6365 for g in cached.grants {
6366 if !roster.grants.iter().any(|x| x.member == g.member) {
6367 roster.grants.push(g);
6368 }
6369 }
6370 let my_hex = my_pk.to_hex();
6371 let member_hex = member.to_hex();
6372 if !roster.is_authorized_in(&my_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6375 return Err("not authorized to manage this channel's access".to_string());
6376 }
6377 if member_hex != owner_hex {
6382 if !roster.can_act_on_member(&my_hex, Some(&owner_hex), &member_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6383 return Err("you do not outrank this member".to_string());
6384 }
6385 require_grant_head(community, &view, &member_hex)?;
6389 }
6390 let role_id = roster
6395 .channel_roles(&chan_hex)
6396 .into_iter()
6397 .find(|r| r.permissions == crate::community::roles::Permissions::empty())
6398 .map(|r| r.role_id.clone())
6399 .ok_or("channel has no permission-less access role to grant")?;
6400
6401 let mut role_ids: Vec<String> = roster.roles_of(&member_hex).map(|r| r.role_id.clone()).collect();
6402 if !role_ids.contains(&role_id) {
6403 role_ids.push(role_id.clone());
6404 }
6405 grant_roles_with_roster(transport, community, member, role_ids.clone(), &roster).await?;
6408 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids }));
6409 let bundle = bundle_of_with_overlay(
6411 community,
6412 BundleAudience::Member(*member),
6413 Some(my_pk),
6414 None,
6415 None,
6416 std::slice::from_ref(&role_id),
6417 &[],
6418 );
6419 let signer = crate::signer::active_signer()?;
6420 let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
6421 transport.publish(&wrap, &community.relays).await?;
6422 Ok(())
6423 })
6424 .await
6425}
6426
6427pub async fn revoke_channel_access<T: Transport + ?Sized>(
6433 transport: &T,
6434 community: &CommunityV2,
6435 channel_id: &ChannelId,
6436 member: &PublicKey,
6437) -> Result<(), String> {
6438 crate::db::scoped(async move {
6439 let my_pk = me_pk()?;
6440 let ch = community.channel(channel_id).ok_or("unknown channel")?;
6441 if !ch.private {
6442 return Err("channel is public — there is no access to revoke".to_string());
6443 }
6444 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6445 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6446 let owner_hex = community.owner()?.to_hex();
6447 let view = fetch_authority(transport, community).await;
6450 let mut roster = view.roles.clone();
6451 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6452 for r in cached.roles {
6453 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
6454 roster.roles.push(r);
6455 }
6456 }
6457 for g in cached.grants {
6458 if !roster.grants.iter().any(|x| x.member == g.member) {
6459 roster.grants.push(g);
6460 }
6461 }
6462 let my_hex = my_pk.to_hex();
6463 let member_hex = member.to_hex();
6464 if !roster.is_authorized_in(&my_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6465 return Err("not authorized to manage this channel's access".to_string());
6466 }
6467 if *member == community.owner()? {
6468 return Err("the owner is supreme and cannot be removed".to_string());
6469 }
6470 if !roster.can_act_on_member(&my_hex, Some(&owner_hex), &member_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6474 return Err("you do not outrank this member".to_string());
6475 }
6476 require_grant_head(community, &view, &member_hex)?;
6478 let access_ids = roster.channel_role_ids(&chan_hex);
6479 if access_ids.is_empty() {
6483 return Err("this channel's access role has not folded yet — retry once the control plane serves it".to_string());
6484 }
6485 let remaining: Vec<String> = roster
6486 .roles_of(&member_hex)
6487 .map(|r| r.role_id.clone())
6488 .filter(|id| !access_ids.contains(id))
6489 .collect();
6490 grant_roles_with_roster(transport, community, member, remaining.clone(), &roster).await?;
6491 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids: remaining }));
6492 rekey_channel_excluding(transport, community, channel_id, &roster, &access_ids, std::slice::from_ref(member)).await
6496 })
6497 .await
6498}
6499
6500async fn rekey_channel_excluding<T: Transport + ?Sized>(
6511 transport: &T,
6512 community: &CommunityV2,
6513 channel_id: &ChannelId,
6514 roster: &crate::community::roles::CommunityRoles,
6515 access_ids: &[String],
6516 removed: &[PublicKey],
6517) -> Result<(), String> {
6518 crate::db::scoped(async move {
6519 let lock = super::realtime::follow_lock(community.id());
6521 let _guard = lock.lock().await;
6522 let signer = crate::signer::active_signer()?;
6523 let my_pk = me_pk()?;
6524 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6525 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6526 let ch = community.channel(channel_id).ok_or("unknown channel")?.clone();
6527 let old_key = ch.key.ok_or("we hold no key for this channel, so we cannot rotate it")?;
6528 let new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
6529 let owner = community.owner()?;
6530 let owner_hex = owner.to_hex();
6531
6532 let removed_hexes: std::collections::HashSet<String> = removed.iter().map(|p| p.to_hex()).collect();
6536 let mut recipients: Vec<PublicKey> = vec![my_pk];
6537 if owner != my_pk {
6538 recipients.push(owner);
6539 }
6540 for g in &roster.grants {
6541 if removed_hexes.contains(&g.member) || g.member == owner_hex {
6542 continue;
6543 }
6544 if !g.role_ids.iter().any(|id| access_ids.contains(id)) {
6545 continue;
6546 }
6547 if let Ok(pk) = PublicKey::parse(&g.member) {
6548 if !recipients.contains(&pk) {
6549 recipients.push(pk);
6550 }
6551 }
6552 }
6553 let new_key = mint_or_reuse_rotation_key(&cid_hex, &chan_hex, new_epoch.0)?;
6556 let prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
6557 let mut blobs = Vec::with_capacity(recipients.len());
6558 for r in &recipients {
6559 blobs.push(
6560 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(*channel_id), new_epoch, &new_key)
6561 .await
6562 .map_err(|e| e.to_string())?,
6563 );
6564 }
6565 let group = channel_rekey_group_key(&community.community_root, channel_id, new_epoch);
6566 let at_secs = now_ms() / 1000;
6567 let chunks = rekey::build_rekey_chunks(&signer, my_pk, &group, RekeyScope::Channel(*channel_id), new_epoch, ch.epoch, &prev_commit, &blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
6568 .await
6569 .map_err(|e| e.to_string())?;
6570 for c in &chunks {
6571 transport.publish_durable(c, &community.relays).await?;
6572 }
6573 if crate::db::community::community_protocol(community.id())?.is_none() {
6574 return Err("community removed during channel rekey".to_string());
6575 }
6576 crate::db::community::advance_channel_epoch(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
6578 crate::db::community::store_epoch_key(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
6579
6580 {
6587 let mut rotated = community.clone();
6588 if let Some(c) = rotated.channels.iter_mut().find(|c| c.id == *channel_id) {
6589 c.key = Some(new_key);
6590 c.epoch = new_epoch;
6591 }
6592 match read_channel_pins(&rotated, channel_id) {
6593 Ok(read) if !read.sealed && !read.pins.is_empty() => {
6594 let entries: Vec<super::pins::PinEntry> =
6595 read.pins.iter().map(|p| p.entry.clone()).collect();
6596 if let Some(ch2) = rotated.channel(channel_id).cloned() {
6597 if let Err(e) = publish_pin_list(transport, &rotated, &ch2, &entries).await {
6598 crate::log_warn!("[pins] rotation reseal failed (a curator's next edit heals): {e}");
6599 } else {
6600 crate::log_info!("[pins] resealed {} pin(s) under epoch {}", entries.len(), new_epoch.0);
6601 }
6602 }
6603 }
6604 Ok(read) if read.sealed => {
6605 crate::log_warn!("[pins] rotating a channel whose pin list we cannot read; reseal skipped");
6606 }
6607 _ => {}
6608 }
6609 }
6610 Ok(())
6611 })
6612 .await
6613}
6614
6615pub async fn sever_banned_private_reads<T: Transport + ?Sized>(
6635 transport: &T,
6636 community: &CommunityV2,
6637 targets: &[(PublicKey, Vec<String>)],
6638) -> Result<usize, String> {
6639 crate::db::scoped(async move {
6640 let my_pk = me_pk()?;
6641 ensure_folded_permission(community, &my_pk, crate::community::roles::Permissions::MANAGE_CHANNELS, "severing a banned member's channel reads")?;
6642 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6643 let mut roster = fetch_authority(transport, community).await.roles;
6647 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6648 for r in cached.roles {
6649 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
6650 roster.roles.push(r);
6651 }
6652 }
6653 for g in cached.grants {
6654 if !roster.grants.iter().any(|x| x.member == g.member) {
6655 roster.grants.push(g);
6656 }
6657 }
6658 let owner_hex = community.owner().ok().map(|o| o.to_hex());
6659 let mut rotated = 0usize;
6660 let mut failures: Vec<String> = Vec::new();
6661 for ch in &community.channels {
6662 if !ch.private || ch.key.is_none() {
6663 continue;
6664 }
6665 let chan_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
6666 let excluded: Vec<PublicKey> = targets
6668 .iter()
6669 .filter(|(pk, stripped)| roster.is_entitled(owner_hex.as_deref(), &pk.to_hex(), &chan_hex, stripped, &[]))
6670 .map(|(pk, _)| *pk)
6671 .collect();
6672 if excluded.is_empty() {
6673 continue;
6674 }
6675 let access_ids = roster.channel_role_ids(&chan_hex);
6676 if access_ids.is_empty() {
6679 continue;
6680 }
6681 let held = match crate::db::community::load_community_v2(community.id()) {
6684 Ok(Some(c)) => c,
6685 _ => return Err("community gone during ban severance".to_string()),
6686 };
6687 match rekey_channel_excluding(transport, &held, &ch.id, &roster, &access_ids, &excluded).await {
6688 Ok(()) => rotated += 1,
6689 Err(e) => failures.push(format!("{}: {e}", &chan_hex[..12])),
6690 }
6691 }
6692 if failures.is_empty() {
6693 Ok(rotated)
6694 } else {
6695 Err(format!("{rotated} rotated; failed: {}", failures.join("; ")))
6696 }
6697 })
6698 .await
6699}
6700
6701pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
6704 crate::db::scoped(async move {
6705 let lock = super::realtime::follow_lock(community.id());
6707 let _guard = lock.lock().await;
6708 let my_pk = me_pk()?;
6709 ensure_channel_manager(community, &my_pk)?;
6710 let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
6714 name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
6715 });
6716 meta.deleted = Some(true);
6717 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
6718 publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
6719 let mut updated = community.clone();
6720 updated.channels.retain(|c| c.id.0 != channel_id.0);
6721 crate::db::community::save_community_v2(&updated)?;
6722 Ok(())
6723 })
6724 .await
6725}
6726
6727#[derive(Debug, Default)]
6760pub struct ControlFollow {
6761 pub updated: Option<CommunityV2>,
6762 pub authority_changed: bool,
6763}
6764
6765impl ControlFollow {
6766 pub fn moved(&self) -> bool {
6768 self.updated.is_some() || self.authority_changed
6769 }
6770}
6771
6772pub async fn follow_control<T: Transport + ?Sized>(
6773 transport: &T,
6774 community: &CommunityV2,
6775) -> Result<ControlFollow, String> {
6776 crate::db::scoped(async move {
6777 community.owner()?; let control = control::ControlPlane::of(community);
6779 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6780
6781 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
6787 .into_iter()
6788 .filter(|(_, f)| f.0 == community.root_epoch.0)
6789 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
6790 .collect();
6791
6792 let mut editions: Vec<ParsedEdition> = Vec::new();
6796 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
6797 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
6798 let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false, pins_persist: Vec::new() };
6799 let mut authority = AuthoritySet::owner_only();
6800 let mut truncated = true;
6806 let mut pages_read = 0usize;
6807 for tier in [
6816 crate::community::transport::Evidence::Quorum,
6817 crate::community::transport::Evidence::Full,
6818 ] {
6819 let mut until: Option<u64> = None;
6820 let mut oldest: Option<u64> = None;
6821 truncated = true;
6822 for _ in 0..FOLLOW_MAX_PAGES {
6823 pages_read += 1;
6824 let query = Query {
6825 kinds: vec![stream::KIND_WRAP],
6826 authors: vec![control.pk_hex()],
6827 until,
6828 limit: Some(FOLLOW_PAGE),
6829 evidence: tier,
6830 ..Default::default()
6831 };
6832 let wraps = transport.fetch(&query, &community.relays).await?;
6833 let mut fresh = 0usize;
6837 for w in &wraps {
6838 if !seen_wraps.insert(w.id) {
6839 continue;
6840 }
6841 fresh += 1;
6842 let at = w.created_at.as_secs();
6843 if oldest.is_none_or(|o| at < o) {
6844 oldest = Some(at);
6845 }
6846 if let Ok((ed, _)) = control.open(w) {
6849 if seen.insert(ed.inner_id) {
6850 editions.push(ed);
6851 }
6852 }
6853 }
6854 authority = fold_authority(community, &editions, &floors);
6857 fold = apply_control_fold(community, &editions, &floors, &authority);
6858 let heads_now: std::collections::HashSet<&str> =
6872 authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
6873 let all_accounted = match crate::db::community::get_community_roles(&cid_hex) {
6878 Ok(known) => {
6879 (!known.roles.is_empty() || !known.grants.is_empty())
6886 && known.roles.iter().all(|r| heads_now.contains(r.role_id.as_str()))
6887 && known.grants.iter().all(|g| {
6888 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
6889 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
6890 heads_now.contains(eid.as_str())
6891 })
6892 })
6893 }
6894 Err(e) => {
6895 crate::log_warn!("[v2:control {}] roster read failed mid-page ({e}) — paging on rather than assuming nothing is tracked", &cid_hex[..8.min(cid_hex.len())]);
6896 false
6897 }
6898 };
6899 if !(fold.gapped || authority.gapped) && all_accounted {
6900 truncated = false; break;
6902 }
6903 if fresh == 0 {
6904 truncated = wraps.len() >= FOLLOW_PAGE;
6909 break;
6910 }
6911 until = oldest;
6912 }
6913
6914 if tier == crate::community::transport::Evidence::Full {
6915 break;
6916 }
6917 let stored_so_far = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6918 let heads_so_far: std::collections::HashSet<&str> =
6919 authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
6920 let accounted = stored_so_far.roles.iter().all(|r| !floors.contains_key(&r.role_id) || heads_so_far.contains(r.role_id.as_str()))
6921 && stored_so_far.grants.iter().all(|g| {
6922 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
6923 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
6924 !floors.contains_key(&eid) || heads_so_far.contains(eid.as_str())
6925 })
6926 });
6927 if accounted {
6928 break;
6929 }
6930 crate::log_debug!(
6931 "[v2:control {}] {} stored entit(ies) unaccounted after a Quorum read — re-reading at Full before drawing an absence verdict",
6932 &cid_hex[..8.min(cid_hex.len())],
6933 stored_so_far.roles.len() + stored_so_far.grants.len()
6934 );
6935 }
6936
6937 if crate::db::community::community_protocol(community.id())?.is_none() {
6942 return Ok(ControlFollow::default());
6943 }
6944 for h in fold.heads.iter().chain(authority.heads.iter()) {
6950 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)?;
6951 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)?;
6952 }
6953 let adopted_control_root = adopt_my_control_wrap(community, &editions).await;
6960 let mut authority_changed = false;
6965 let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
6969 if let Some((banned, version)) = &authority.banlist_persist {
6970 let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
6971 crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
6972 let mut after = banned.clone();
6973 before.sort();
6974 after.sort();
6975 authority_changed |= before != after;
6976 }
6977 let newest_roster_at: i64 = editions
6984 .iter()
6985 .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
6986 .map(|e| e.created_at as i64)
6987 .max()
6988 .unwrap_or(0);
6989 let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6995 let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
6996 let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
6997 && stored.grants.iter().all(|g| {
6998 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
6999 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
7000 !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
7001 })
7002 });
7003 let stored_at = crate::db::community::get_community_roles_at(&cid_hex)?;
7008 let seed_only = stored_at == 0;
7017 if !truncated && (stored_complete || seed_only) && newest_roster_at >= stored_at {
7054 authority_changed |= stored != authority.roles;
7055 crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
7056 } else {
7057 let mut blockers: Vec<String> = Vec::new();
7065 for r in &stored.roles {
7066 if floors.contains_key(&r.role_id) && !head_ents.contains(r.role_id.as_str()) {
7067 blockers.push(format!("role:{}", &r.role_id[..8.min(r.role_id.len())]));
7068 }
7069 }
7070 for g in &stored.grants {
7071 if let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) {
7072 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
7073 if floors.contains_key(&eid) && !head_ents.contains(eid.as_str()) {
7074 blockers.push(format!("grant:{}", &g.member[..8.min(g.member.len())]));
7075 }
7076 }
7077 }
7078 {
7083 use std::collections::BTreeMap;
7084 let mut fate: BTreeMap<String, (u64, u64, String, &str)> = BTreeMap::new();
7085 for e in &editions {
7086 if e.vsk != vsk::ROLE && e.vsk != vsk::GRANT {
7087 continue;
7088 }
7089 let kind = if e.vsk == vsk::ROLE { "role" } else { "grant" };
7090 let eid = crate::simd::hex::bytes_to_hex_32(&e.entity_id);
7091 let slot = fate.entry(eid).or_insert((0, 0, String::new(), kind));
7092 slot.0 += 1;
7093 if e.version > slot.1 {
7094 slot.1 = e.version;
7095 slot.2 = e.author.to_hex()[..8].to_string();
7096 slot.3 = kind;
7097 }
7098 }
7099 let head_now: std::collections::HashSet<&str> =
7100 authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
7101 let rows: Vec<String> = fate
7102 .iter()
7103 .map(|(eid, (n, v, author, kind))| {
7104 format!(
7105 "{}[{kind}]:{}ed/v{}/by{}{}{}",
7106 &eid[..8], n, v, author,
7107 if head_now.contains(eid.as_str()) {
7108 "/HEAD".to_string()
7109 } else {
7110 AUTHORITY_REFUSALS
7111 .with(|c| c.borrow().get(eid.as_str()).copied())
7112 .map(|r| {
7113 let extra = MISSING_ROLE_REFS
7114 .with(|m| m.borrow().get(eid.as_str()).cloned())
7115 .map(|ids| format!(" wants:{ids}"))
7116 .unwrap_or_default();
7117 format!("/DROPPED({r}{extra})")
7118 })
7119 .unwrap_or_else(|| "/dropped(no candidate survived the fold)".into())
7120 },
7121 floors.get(eid).map(|f| format!("/floor@v{}", f.0)).unwrap_or_default(),
7122 )
7123 })
7124 .collect();
7125 crate::log_warn!(
7130 "[v2:control {}] fold accepted roles: [{}]",
7131 &cid_hex[..8.min(cid_hex.len())],
7132 authority.roles.roles.iter().map(|r| format!("{}@pos{}", &r.role_id[..8.min(r.role_id.len())], r.position)).collect::<Vec<_>>().join(" ")
7133 );
7134 crate::log_warn!(
7135 "[v2:control {}] owner={} — roster editions in window: [{}]",
7136 &cid_hex[..8.min(cid_hex.len())],
7137 community.owner().map(|o| o.to_hex()[..8].to_string()).unwrap_or_else(|_| "UNPROVEN".into()),
7138 rows.join(" ")
7139 );
7140 }
7141 crate::log_warn!(
7142 "[v2:control {}] roster NOT cached (truncated={} authority_gapped={} doc_gapped={} stored_complete={} newest_roster_at={} stored_at={}) — stored {}r/{}g, fold offers {}r/{}g from {} edition(s) over {} page(s), blocked by [{}]",
7143 &cid_hex[..8.min(cid_hex.len())], truncated, authority.gapped, fold.gapped, stored_complete, newest_roster_at, stored_at,
7144 stored.roles.len(), stored.grants.len(), authority.roles.roles.len(), authority.roles.grants.len(),
7145 editions.len(), pages_read,
7146 blockers.join(", ")
7147 );
7148 }
7149 if !truncated && !authority.gapped && !fold.gapped {
7155 if let Ok(owner) = community.owner() {
7156 let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
7157 let live = flatten_link_sets(&sets);
7158 let mut before = crate::db::community::get_community_invite_registry(&cid_hex).unwrap_or_default();
7159 before.sort();
7160 if before != live {
7161 crate::db::community::set_community_invite_registry(&cid_hex, &live)?;
7162 authority_changed = true;
7163 }
7164 crate::db::community::replace_invite_link_sets(&cid_hex, &sets)?;
7167 }
7168 }
7169 for (channel_hex, content, version, author_npub, created_at) in &fold.pins_persist {
7173 match crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64) {
7174 Ok(true) => {
7175 crate::log_info!("[pins] fold adopted v{} for channel {}", version, &channel_hex[..12]);
7176 crate::emit_event(
7177 "community_pins_updated",
7178 &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
7179 );
7180 note_pins_modified(channel_hex, *version, author_npub, *created_at).await;
7181 }
7182 Ok(false) => {}
7183 Err(e) => crate::log_warn!("[pins] fold persist failed: {e}"),
7184 }
7185 }
7186 if authority_changed {
7191 crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
7192 }
7193 {
7197 static UPGRADED: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
7198 let first = UPGRADED
7199 .lock()
7200 .map(|mut set| set.get_or_insert_with(Default::default).insert(cid_hex.clone()))
7201 .unwrap_or(false);
7202 if first {
7203 let _ = upgrade_admin_role_pin_bit(transport, community).await;
7204 }
7205 }
7206 let mut updated = fold.updated;
7210 if let Some(cr) = adopted_control_root {
7211 let mut u = updated.take().unwrap_or_else(|| community.clone());
7212 u.control_root = Some(cr);
7213 updated = Some(u);
7214 }
7215 match updated {
7216 Some(u) => {
7217 crate::db::community::save_community_v2(&u)?;
7218 Ok(ControlFollow { updated: Some(u), authority_changed })
7219 }
7220 None => Ok(ControlFollow { updated: None, authority_changed }),
7221 }
7222 })
7223 .await
7224}
7225
7226async fn adopt_my_control_wrap(community: &CommunityV2, editions: &[ParsedEdition]) -> Option<[u8; 32]> {
7237 use nostr_sdk::prelude::AsyncNip44;
7238 let held_pk = community.control_pk?;
7239 if community.control_root.is_some() {
7240 return None;
7241 }
7242 let my_pk = me_pk().ok()?;
7243 let signer = crate::signer::active_signer().ok()?;
7244 let my_eid = super::derive::grant_locator(community.id(), &my_pk.to_bytes());
7245 let mut candidates: Vec<&ParsedEdition> = editions
7246 .iter()
7247 .filter(|e| e.vsk == vsk::GRANT && e.entity_id == my_eid)
7248 .collect();
7249 candidates.sort_by_key(|e| std::cmp::Reverse(e.version));
7250 for ed in candidates {
7251 let Some(wrap_b64) = super::roles::parse_grant_control_wrap(&ed.content) else { continue };
7252 let Ok(plain_b64) = signer.nip44_decrypt_async(&ed.author, &wrap_b64).await else { continue };
7255 let Ok(plain) = base64_simd::STANDARD.decode_to_vec(plain_b64.as_bytes()) else { continue };
7256 let Ok((epoch, root)) = super::rekey::parse_control_wrap(&plain) else { continue };
7257 if epoch != community.root_epoch {
7258 continue;
7259 }
7260 if super::derive::control_signer_group_key(&root, community.id(), epoch).pk() != held_pk {
7261 continue;
7262 }
7263 crate::log_info!("[v2] adopted the staff write key from my Grant (epoch {})", epoch.0);
7264 return Some(root);
7265 }
7266 None
7267}
7268
7269const MAX_WALK_STEPS: usize = 128;
7280const FOLLOW_MAX_PAGES: usize = 32;
7281const FOLLOW_PAGE: usize = 500;
7282const COMPACT_MAX_PAGES: usize = 512;
7287
7288#[derive(Clone)]
7290struct FoldedHead {
7291 entity_hex: String,
7292 version: u64,
7293 self_hash: [u8; 32],
7294 inner_id: [u8; 32],
7295}
7296
7297struct ControlFold {
7303 updated: Option<CommunityV2>,
7304 heads: Vec<FoldedHead>,
7305 gapped: bool,
7306 pins_persist: Vec<(String, String, u64, String, u64)>,
7310}
7311
7312type Floor = (u64, [u8; 32], Option<[u8; 32]>);
7314type Floors = std::collections::HashMap<String, Floor>;
7315
7316fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
7338 use crate::community::roles::Permissions;
7339 use std::collections::BTreeMap;
7340
7341 let owner_hex = community.owner().ok().map(|o| o.to_hex());
7342
7343 let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
7344 for e in editions {
7345 groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
7346 }
7347
7348 let mut out = community.clone();
7349 let mut changed = false;
7350 let mut heads = Vec::new();
7351 let mut gapped = false;
7352 let mut pins_persist = Vec::new();
7353 let pins_by_eid: std::collections::HashMap<[u8; 32], String> = community
7358 .channels
7359 .iter()
7360 .map(|ch| (super::derive::pins_locator(community.id(), &ch.id), crate::simd::hex::bytes_to_hex_32(&ch.id.0)))
7361 .collect();
7362 for ((vsk_code, eid), group) in &groups {
7363 let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
7369 let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
7370 let pins_channel = (vsk_code == vsk::PINS).then(|| pins_by_eid.get(eid)).flatten();
7371 if !is_meta && !is_channel && pins_channel.is_none() {
7372 continue;
7373 }
7374 let required = if is_meta {
7379 Permissions::MANAGE_METADATA
7380 } else if is_channel {
7381 Permissions::MANAGE_CHANNELS
7382 } else {
7383 Permissions::PIN_MESSAGES
7384 };
7385 let authed: Vec<&ParsedEdition> = group
7386 .iter()
7387 .copied()
7388 .filter(|e| {
7389 let author = e.author.to_hex();
7390 !authority.banned.contains(&author)
7393 && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
7394 && citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
7398 })
7399 .collect();
7400 if authed.is_empty() {
7401 continue;
7402 }
7403 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
7404 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
7405 let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
7406 gapped |= entity_gapped;
7407 let Some(hi) = hi else { continue };
7408
7409 let head = authed[hi];
7410 heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
7411 if is_meta {
7412 if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
7413 changed |= apply_community_metadata(&mut out, meta);
7414 }
7415 } else if is_channel {
7416 if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
7417 changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
7421 }
7422 } else if let Some(channel_hex) = pins_channel {
7423 use nostr_sdk::prelude::ToBech32;
7427 let author_npub = head
7428 .author
7429 .to_bech32()
7430 .unwrap_or_else(|_| head.author.to_hex());
7431 pins_persist.push((channel_hex.clone(), head.content.clone(), head.version, author_npub, head.created_at));
7432 }
7433 }
7434 ControlFold { updated: changed.then_some(out), heads, gapped, pins_persist }
7435}
7436
7437fn fold_head(fold_eds: &[version::Edition], floor: Option<&Floor>) -> (Option<usize>, bool) {
7444 let floor_v = floor.map(|f| f.0).unwrap_or(0);
7445 if floor_v == 0 {
7446 return (version::bootstrap_head(fold_eds, 0), false);
7447 }
7448 let floor_hash = floor.map(|f| &f.1);
7449 let held_inner = floor.and_then(|f| f.2);
7450 let result = version::fold(fold_eds, floor_v, floor_hash);
7451 if result.anchored {
7452 return (result.head, result.gap); }
7454 if result.head.is_none() && !result.gap {
7455 return (None, false); }
7457 let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
7461 let win_hash = match fork {
7462 Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
7463 _ => return (None, true), };
7465 let re = version::fold(fold_eds, floor_v, Some(&win_hash));
7466 if !re.anchored {
7467 return (None, true);
7468 }
7469 (re.head, re.gap)
7470}
7471
7472struct AuthoritySet {
7477 roles: crate::community::roles::CommunityRoles,
7478 banned: std::collections::BTreeSet<String>,
7479 heads: Vec<FoldedHead>,
7480 gapped: bool,
7481 banlist_persist: Option<(Vec<String>, u64)>,
7485 banned_at: std::collections::BTreeMap<String, u64>,
7489}
7490
7491impl AuthoritySet {
7492 fn owner_only() -> Self {
7495 AuthoritySet {
7496 roles: Default::default(),
7497 banned: Default::default(),
7498 heads: vec![],
7499 gapped: false,
7500 banlist_persist: None,
7501 banned_at: Default::default(),
7502 }
7503 }
7504}
7505
7506fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
7513 use crate::community::roles::Permissions;
7514 use std::collections::BTreeMap;
7515
7516 let cid = community.id();
7517 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
7518 let owner = community.owner().ok();
7519 let owner_hex = owner.map(|o| o.to_hex());
7520 let banlist_eid = super::derive::banlist_locator(cid);
7521 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
7522
7523 let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
7524 for e in editions {
7525 if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
7526 groups.entry(e.entity_id).or_default().push(e);
7527 }
7528 }
7529
7530 let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
7538 let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
7539 let mut gapped = false;
7540
7541 for (eid, group) in &groups {
7542 if *eid == banlist_eid {
7544 continue;
7545 }
7546 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
7547 let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
7548 let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
7549 gapped |= entity_gapped;
7550 let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
7551
7552 for p in group {
7553 if p.version < floor_v {
7555 continue;
7556 }
7557 let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
7558 match p.vsk.as_str() {
7559 vsk::ROLE => {
7560 if let Some(role) = super::roles::parse_role_content(&p.content) {
7562 if role.role_id == entity_hex && role.position != 0 {
7563 role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
7564 }
7565 }
7566 }
7567 vsk::GRANT => {
7568 if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
7569 if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
7570 if super::derive::grant_locator(cid, &member) == *eid {
7571 grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
7572 grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
7573 }
7574 }
7575 }
7576 }
7577 _ => {}
7578 }
7579 }
7580 }
7581 for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
7582 cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
7583 }
7584
7585 let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
7586 let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty, None);
7589
7590 let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
7601 let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
7606 let banlist_authored: Vec<&ParsedEdition> = groups
7607 .get(&banlist_eid)
7608 .map(|g| {
7609 g.iter()
7610 .copied()
7611 .filter(|e| {
7612 let ah = e.author.to_hex();
7613 !banned_authors.contains(ah.as_str())
7614 && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
7615 && citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
7616 })
7617 .collect()
7618 })
7619 .unwrap_or_default();
7620 let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
7626 for p in &banlist_authored {
7627 for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
7628 if owner_hex.as_deref() == Some(t.as_str()) {
7629 continue;
7630 }
7631 let slot = banned_at.entry(t).or_insert(0);
7632 *slot = (*slot).max(p.created_at);
7633 }
7634 }
7635 let mut banlist_persist: Option<(Vec<String>, u64)> = None;
7636 let mut banlist_head: Option<FoldedHead> = None;
7637 let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
7638 persisted_banned.into_iter().collect()
7639 } else {
7640 let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
7641 let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
7642 gapped |= g;
7643 match hi {
7644 Some(hi) => {
7645 let head = banlist_authored[hi];
7646 let ah = head.author.to_hex();
7647 let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
7648 .unwrap_or_default()
7649 .into_iter()
7650 .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
7651 .take(super::roles::MAX_BANLIST)
7652 .collect();
7653 banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
7654 banlist_persist = Some((list.clone(), head.version));
7655 list.into_iter().collect()
7656 }
7657 None => persisted_banned.into_iter().collect(),
7658 }
7659 };
7660
7661 UNCITED_ACCEPTED.with(|n| *n.borrow_mut() = 0);
7665 let mut refusals: std::collections::BTreeMap<String, &'static str> = Default::default();
7666 let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned, Some(&mut refusals));
7667 AUTHORITY_REFUSALS.with(|c| *c.borrow_mut() = refusals);
7668 if let Some(bh) = banlist_head {
7669 heads.push(bh);
7670 }
7671
7672 if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
7677 authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
7678 authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
7679 let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
7680 authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
7681 authorized.grants.retain(|g| !g.role_ids.is_empty());
7682 }
7683
7684 AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
7685}
7686
7687struct AuthorityCand {
7690 role: Option<crate::community::roles::Role>,
7691 grant: Option<crate::community::roles::MemberGrant>,
7692 author: PublicKey,
7693 head: FoldedHead,
7694 citation: Option<crate::community::edition::AuthorityCitation>,
7697}
7698
7699fn citation_ok_in_fold(
7708 cid: &crate::community::CommunityId,
7709 heads: &[FoldedHead],
7710 owner_hex: Option<&str>,
7711 author: &PublicKey,
7712 citation: Option<&crate::community::edition::AuthorityCitation>,
7713) -> bool {
7714 let actor_hex = author.to_hex();
7715 if owner_hex == Some(actor_hex.as_str()) {
7716 return true;
7717 }
7718 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
7719 let as_entity: Vec<crate::community::roster::EntityHead> = heads
7720 .iter()
7721 .map(|h| crate::community::roster::EntityHead {
7722 entity_hex: h.entity_hex.clone(),
7723 version: h.version,
7724 self_hash: h.self_hash,
7725 inner_id: h.inner_id,
7726 citation: None,
7727 })
7728 .collect();
7729 crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
7730}
7731
7732thread_local! {
7733 static UNCITED_ACCEPTED: std::cell::RefCell<u32> = const { std::cell::RefCell::new(0) };
7736 static MISSING_ROLE_REFS: std::cell::RefCell<std::collections::BTreeMap<String, String>> =
7738 const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
7739 static AUTHORITY_REFUSALS: std::cell::RefCell<std::collections::BTreeMap<String, &'static str>> =
7742 const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
7743}
7744
7745fn select_authorized(
7754 cid: &crate::community::CommunityId,
7755 role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
7756 grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
7757 owner_hex: Option<&str>,
7758 excluded: &std::collections::BTreeSet<String>,
7759 mut why: Option<&mut std::collections::BTreeMap<String, &'static str>>,
7763) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
7764 use crate::community::roles::{CommunityRoles, Permissions};
7765 let mut accepted = CommunityRoles::default();
7766 let mut heads: Vec<FoldedHead> = Vec::new();
7767 let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
7772 for _ in 0..bound {
7773 let mut next = CommunityRoles::default();
7774 let mut next_heads: Vec<FoldedHead> = Vec::new();
7775
7776 for cands in role_cands.values() {
7777 let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
7790 let mut standing: Option<u32> = None;
7791 let mut i = cands.len();
7792 while i > 0 {
7793 let hi = i;
7794 let ver = cands[i - 1].head.version;
7795 while i > 0 && cands[i - 1].head.version == ver {
7796 i -= 1;
7797 }
7798 for c in cands[i..hi].iter().rev() {
7800 let Some(role) = &c.role else { continue };
7801 let ah = c.author.to_hex();
7802 let note = |w: &mut Option<&mut std::collections::BTreeMap<String, &'static str>>, r: &'static str| {
7803 if let Some(m) = w.as_mut() {
7804 m.insert(c.head.entity_hex.clone(), r);
7805 }
7806 };
7807 if excluded.contains(&ah) || role.position == 0 {
7808 note(&mut why, "role: author banned or position 0");
7809 continue;
7810 }
7811 if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
7812 note(&mut why, "role: author lacks MANAGE_ROLES over this position");
7813 continue;
7814 }
7815 if let Some(prev) = standing {
7816 if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
7817 note(&mut why, "role: author cannot outrank the position it replaces");
7818 continue;
7819 }
7820 }
7821 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
7833 if c.citation.is_some() {
7834 note(&mut why, "role: cited a grant we cannot resolve");
7835 continue;
7836 }
7837 UNCITED_ACCEPTED.with(|n| *n.borrow_mut() += 1);
7838 }
7839 admissible.insert(c.head.self_hash);
7840 standing = Some(role.position);
7841 break;
7842 }
7843 }
7844 for c in cands {
7845 let Some(role) = &c.role else { continue };
7846 if !admissible.contains(&c.head.self_hash) {
7847 continue;
7848 }
7849 next.roles.push(role.clone());
7850 next_heads.push(c.head.clone());
7851 break; }
7853 }
7854 for cands in grant_cands.values() {
7855 for c in cands {
7856 let c2 = c;
7857 let Some(grant) = &c.grant else { continue };
7858 let ah = c.author.to_hex();
7859 let note = |w: &mut Option<&mut std::collections::BTreeMap<String, &'static str>>, r: &'static str| {
7860 if let Some(m) = w.as_mut() {
7861 m.insert(c.head.entity_hex.clone(), r);
7862 }
7863 };
7864 if excluded.contains(&ah) || excluded.contains(&grant.member) {
7865 note(&mut why, "grant: author or member banned");
7866 continue;
7867 }
7868 let resolved: Vec<(String, u32)> = grant
7885 .role_ids
7886 .iter()
7887 .filter_map(|rid| accepted.role(rid).map(|r| (rid.clone(), r.position)))
7888 .collect();
7889 let positions: Vec<u32> = resolved.iter().map(|(_, p)| *p).collect();
7890 if resolved.is_empty() && !grant.role_ids.is_empty() {
7891 if let Some(m) = why.as_mut() {
7895 let missing: Vec<String> = grant
7896 .role_ids
7897 .iter()
7898 .filter(|rid| accepted.role(rid).is_none())
7899 .map(|rid| rid[..8.min(rid.len())].to_string())
7900 .collect();
7901 MISSING_ROLE_REFS.with(|c| {
7902 c.borrow_mut().insert(c2.head.entity_hex.clone(), missing.join("+"));
7903 });
7904 m.insert(c2.head.entity_hex.clone(), "grant: references a role not in the accepted roster");
7905 }
7906 continue;
7907 }
7908 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
7919 if c.citation.is_some() {
7920 note(&mut why, "grant: cited a grant we cannot resolve");
7921 continue;
7922 }
7923 if resolved.is_empty() {
7924 note(&mut why, "grant: UNCITED revocation — a strip must cite its authority");
7925 continue;
7926 }
7927 UNCITED_ACCEPTED.with(|n| *n.borrow_mut() += 1);
7928 }
7929 if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
7930 && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
7931 {
7932 next_heads.push(c.head.clone());
7936 if !resolved.is_empty() {
7937 let mut g = grant.clone();
7940 g.role_ids = resolved.iter().map(|(rid, _)| rid.clone()).collect();
7941 next.grants.push(g);
7942 }
7943 break;
7944 }
7945 }
7946 }
7947
7948 let converged = next.roles == accepted.roles && next.grants == accepted.grants;
7949 accepted = next;
7950 heads = next_heads;
7951 if converged {
7952 break;
7953 }
7954 }
7955 (accepted, heads)
7956}
7957
7958fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
7962 let mut changed = false;
7963 if out.name != meta.name {
7964 out.name = meta.name;
7965 changed = true;
7966 }
7967 if out.description != meta.description {
7968 out.description = meta.description;
7969 changed = true;
7970 }
7971 if out.icon != meta.icon {
7975 out.icon = meta.icon;
7976 changed = true;
7977 }
7978 if out.banner != meta.banner {
7979 out.banner = meta.banner;
7980 changed = true;
7981 }
7982 if out.meta_custom != meta.custom {
7985 out.meta_custom = meta.custom;
7986 changed = true;
7987 }
7988 if out.meta_extra != meta.extra {
7989 out.meta_extra = meta.extra;
7990 changed = true;
7991 }
7992 let relays = crate::community::cap_relays(meta.relays);
8000 if !relays.is_empty() && out.relays != relays {
8001 out.relays = relays;
8002 changed = true;
8003 }
8004 changed
8005}
8006
8007fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
8012 let deleted = meta.deleted.unwrap_or(false);
8013 if deleted {
8014 let before = out.channels.len();
8015 out.channels.retain(|c| c.id.0 != id.0);
8016 return out.channels.len() != before;
8017 }
8018 match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
8019 Some(existing) => {
8020 let mut changed = false;
8021 if existing.name != meta.name {
8022 existing.name = meta.name;
8023 changed = true;
8024 }
8025 if existing.voice != meta.voice {
8028 existing.voice = meta.voice;
8029 changed = true;
8030 }
8031 if existing.meta_custom != meta.custom {
8032 existing.meta_custom = meta.custom;
8033 changed = true;
8034 }
8035 if existing.meta_extra != meta.extra {
8036 existing.meta_extra = meta.extra;
8037 changed = true;
8038 }
8039 if !meta.private && (existing.private || existing.key.is_some()) {
8048 existing.private = false;
8049 existing.key = None;
8050 changed = true;
8051 }
8052 changed
8053 }
8054 None if !meta.private => {
8055 out.channels.push(ChannelV2 {
8058 id,
8059 name: meta.name,
8060 private: false,
8061 key: None,
8062 epoch: out.root_epoch,
8063 voice: meta.voice,
8064 meta_custom: meta.custom,
8065 meta_extra: meta.extra,
8066 });
8067 true
8068 }
8069 None => {
8070 out.channels.push(ChannelV2 {
8076 id,
8077 name: meta.name,
8078 private: true,
8079 key: None,
8080 epoch: Epoch(0),
8081 voice: meta.voice,
8082 meta_custom: meta.custom,
8083 meta_extra: meta.extra,
8084 });
8085 true
8086 }
8087 }
8088}
8089
8090pub struct RekeyFollow {
8094 pub updated: Option<CommunityV2>,
8097 pub self_removed: bool,
8100 pub dissolved: bool,
8103 pub wedged: bool,
8112}
8113
8114const MAX_ADDRESSING_ROOTS: usize = 8;
8121
8122pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
8131 let mut roots: Vec<[u8; 32]> = vec![cur_root];
8132 let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
8133 .unwrap_or_default();
8134 archived.sort_by_key(|a| std::cmp::Reverse(a.0 .0));
8135 for (_, r) in archived {
8136 if !roots.contains(&r) {
8137 roots.push(r);
8138 }
8139 }
8140 roots.truncate(MAX_ADDRESSING_ROOTS);
8141 roots
8142}
8143
8144pub async fn explain_epoch_state<T: Transport + ?Sized>(
8188 transport: &T,
8189 community: &CommunityV2,
8190) -> Result<serde_json::Value, String> {
8191 let my_xonly = me_pk()?.to_bytes();
8192 let owner = community.owner()?;
8193 let owner_hex = owner.to_hex();
8194 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8195 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
8196 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
8197 let held_epoch = community.root_epoch;
8198 let held_key = community.community_root;
8199 let next = Epoch(held_epoch.0.saturating_add(1));
8200 let group = base_rekey_group_key(&held_key, community.id(), next);
8201 let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
8202 let rotations = rekey::collect_rotations(&chunks);
8203
8204 let reports: Vec<serde_json::Value> = rotations
8205 .iter()
8206 .map(|r| {
8207 let rotator_is_owner = r.rotator == owner;
8208 let rotator_authorized = rotator_is_owner
8211 || (!banned.contains(&r.rotator.to_hex())
8212 && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
8213 let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
8214 let epoch_ok = r.new_epoch.0 == next.0;
8215 let complete = r.is_complete();
8216 let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
8217 let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
8218 let owner_kept = r.rotator == owner
8223 || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
8224 let verdict = if !rotator_authorized {
8226 "REJECTED: rotator holds no BAN authority in the folded roster"
8227 } else if !scope_ok {
8228 "REJECTED: scope is not Root"
8229 } else if !epoch_ok {
8230 "REJECTED: new_epoch != held+1"
8231 } else if !complete {
8232 "WAIT: rotation incomplete (missing chunk) — never concludes removal"
8233 } else if continuity != "Extends" {
8234 "REJECTED: continuity does not extend my held root (FORK/GAP)"
8235 } else if has_my_blob {
8236 "ADOPT: authorized + complete + continuous + my blob present"
8237 } else {
8238 "REMOVED: complete authorized rotation with no blob for me"
8239 };
8240 serde_json::json!({
8241 "rotator": r.rotator.to_hex(),
8242 "rotator_is_recorded_owner": rotator_is_owner,
8243 "rotator_authorized_ban": rotator_authorized,
8244 "scope_is_root": scope_ok,
8245 "new_epoch": r.new_epoch.0,
8246 "prev_epoch": r.prev_epoch.0,
8247 "declared_chunks": r.declared_chunks,
8248 "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
8249 "is_complete": complete,
8250 "continuity_vs_held_root": continuity,
8251 "my_blob_present": has_my_blob,
8252 "owner_kept": owner_kept,
8253 "blob_count": r.blobs.len(),
8254 "severing": r.severed,
8255 "verdict": verdict,
8256 })
8257 })
8258 .collect();
8259
8260 let roster_at = crate::db::community::get_community_roles_at(&cid_hex).unwrap_or(0);
8265 let admin_count = roster.grants.iter().filter(|g| roster.is_admin(&g.member)).count();
8266 let held = serde_json::json!({
8267 "root_epoch": held_epoch.0,
8268 "roster_roles": roster.roles.len(),
8269 "roster_grants": roster.grants.len(),
8270 "roster_admins": admin_count,
8271 "roster_provenance_at": roster_at,
8275 "banlist_entries": banned.len(),
8276 "sever_marks": sever_marks(&cid_hex).keys().copied().collect::<Vec<_>>(),
8277 "control_plane_split": community.control_pk.is_some(),
8278 "private_channels": community.channels.iter().filter(|c| c.private).count(),
8279 "channels_awaiting_key": community.channels.iter().filter(|c| c.private && c.key.is_none()).count(),
8280 });
8281
8282 let summary = if rotations.is_empty() {
8285 "UP TO DATE (or the plane is unreachable): no rotation offered at held+1"
8286 } else if reports.iter().any(|r| r["verdict"].as_str().is_some_and(|v| v.starts_with("ADOPT"))) {
8287 "BEHIND: an adoptable rotation is waiting — run a sync"
8288 } else {
8289 "WEDGED: a rotation exists at held+1 and every one was refused (see verdicts)"
8290 };
8291
8292 Ok(serde_json::json!({
8293 "summary": summary,
8294 "recorded_owner": owner.to_hex(),
8295 "held": held,
8296 "held_root_epoch": held_epoch.0,
8297 "probing_next_epoch": next.0,
8298 "base_plane_pk": group.pk_hex(),
8299 "raw_chunks_parsed": chunks.len(),
8300 "rotations_found": rotations.len(),
8301 "rotations": reports,
8302 }))
8303}
8304
8305pub async fn follow_rekeys<T: Transport + ?Sized>(
8306 transport: &T,
8307 community: &CommunityV2,
8308 session: &std::sync::Arc<crate::db::Session>,
8309) -> Result<RekeyFollow, String> {
8310 crate::db::scoped(async move {
8311 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8314 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
8315 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true, wedged: false });
8316 }
8317 if is_dissolved(transport, community).await {
8323 if session.is_live() {
8324 let _ = crate::db::community::set_community_dissolved(&cid_hex);
8325 }
8326 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true, wedged: false });
8327 }
8328 let signer = crate::signer::active_signer()?;
8329 let my_pk = me_pk()?;
8330 let my_xonly = my_pk.to_bytes();
8331 let owner = community.owner()?;
8332 let owner_hex = owner.to_hex();
8333 let mut cur = community.clone();
8334 let mut changed = false;
8335 let mut wedged = false;
8337
8338 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
8350 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
8351 let me_hex = my_pk.to_hex();
8352 let cited_ok = |rot: &rekey::Rotation| -> bool {
8361 citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
8362 };
8363 let channel_rotator_ok = |rotator: &PublicKey| -> bool {
8364 if *rotator == owner {
8365 return true;
8366 }
8367 let rh = rotator.to_hex();
8368 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
8369 };
8370 let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
8375 if *rotator == owner {
8376 return true;
8377 }
8378 let rh = rotator.to_hex();
8379 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
8380 };
8381 let base_rotator_ok = |rotator: &PublicKey| -> bool {
8387 if *rotator == owner {
8388 return true;
8389 }
8390 let rh = rotator.to_hex();
8391 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
8392 };
8393 let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
8399 if *rotator == owner {
8400 return true;
8401 }
8402 let rh = rotator.to_hex();
8403 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
8404 };
8405
8406 let base_admissible = |r: &rekey::Rotation| -> bool {
8413 if r.rotator == owner {
8414 return true; }
8416 if !cited_ok(r) {
8420 return false;
8421 }
8422 let rotator_hex = r.rotator.to_hex();
8423 let has_blob = |xonly: &[u8; 32]| {
8424 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
8425 };
8426 if !has_blob(&owner.to_bytes()) {
8428 return false;
8429 }
8430 for g in &roster.grants {
8431 if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
8432 continue; }
8434 if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
8436 if let Ok(pk) = PublicKey::from_hex(&g.member) {
8437 if !has_blob(&pk.to_bytes()) {
8438 return false; }
8440 }
8441 }
8442 }
8443 true
8444 };
8445
8446 if cur.root_epoch.0 > 0 {
8467 let prior = crate::db::community::held_epoch_key(
8468 &cid_hex,
8469 crate::community::SERVER_ROOT_SCOPE_HEX,
8470 cur.root_epoch.0 - 1,
8471 )
8472 .ok()
8473 .flatten();
8474 if let Some(prior_root) = prior {
8475 let group = base_rekey_group_key(&prior_root, cur.id(), cur.root_epoch);
8476 match fetch_rekey_chunks(transport, &cur.relays, &group).await {
8477 Ok(siblings) if !siblings.is_empty() => {
8478 let held_before = cur.community_root;
8479 match advance_scope(
8480 &[(siblings, Some((Epoch(cur.root_epoch.0 - 1), prior_root)))],
8481 RekeyScope::Root,
8482 cur.id(),
8483 &base_rotator_ok,
8484 &base_rotator_outranks_me,
8485 &base_admissible,
8486 &signer,
8487 &my_xonly,
8488 cur.root_epoch,
8489 )
8490 .await
8491 {
8492 Advance::Adopt { new_key, control_pk, control_root, .. } if new_key < held_before => {
8494 crate::log_warn!(
8495 "[v2:follow {}] same-epoch fork at epoch {} — converging DOWN onto the canonical sibling",
8496 &cid_hex[..8.min(cid_hex.len())], cur.root_epoch.0
8497 );
8498 cur.community_root = new_key;
8499 cur.control_pk = control_pk.and_then(|pk| PublicKey::from_slice(&pk).ok());
8500 cur.control_root = control_root;
8501 if let Err(e) = crate::db::community::store_epoch_key(
8502 &cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, cur.root_epoch.0, &new_key,
8503 ) {
8504 crate::log_warn!("v2: converged epoch-key archive failed: {e}");
8505 }
8506 if matches!(crate::db::community::community_protocol(community.id()), Ok(Some(_))) {
8507 let _ = crate::db::community::save_community_v2(&cur);
8508 }
8509 changed = true;
8510 }
8511 _ => {}
8516 }
8517 }
8518 Ok(_) => {}
8519 Err(e) => crate::log_warn!(
8520 "[v2:follow {}] same-epoch fork check failed (will retry): {}",
8521 &cid_hex[..8.min(cid_hex.len())], e
8522 ),
8523 }
8524 }
8525 }
8526
8527 const MAX_STEPS: usize = MAX_WALK_STEPS;
8531 const CHANNEL_FAN_CONCURRENCY: usize = 8;
8534 let mut severed_hops: Vec<u64> = Vec::new();
8537 for _ in 0..MAX_STEPS {
8538 let mut advanced = false;
8539
8540 let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
8545
8546 let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
8549 let fan: Vec<(ChannelId, Option<[u8; 32]>, Epoch, Epoch)> = channel_ids
8555 .iter()
8556 .filter_map(|cid| {
8557 cur.channel(cid).map(|ch| {
8558 (*cid, ch.key, ch.epoch, Epoch(ch.epoch.0.saturating_add(1)))
8559 })
8560 })
8561 .collect();
8562 let mut prefetched: std::collections::HashMap<([u8; 32], usize), Vec<rekey::RekeyChunk>> =
8563 std::collections::HashMap::new();
8564 {
8565 use futures_util::stream::StreamExt;
8566 let jobs: Vec<(ChannelId, usize, GroupKey, Epoch)> = fan
8569 .iter()
8570 .flat_map(|(cid, _, _, next)| {
8571 addressing_roots.iter().enumerate().map(move |(ri, root)| {
8572 (*cid, ri, channel_rekey_group_key(root, cid, *next), *next)
8573 })
8574 })
8575 .collect();
8576 let relays = &cur.relays;
8577 let mut results = futures_util::stream::iter(jobs)
8578 .map(|(cid, ri, group, next)| async move {
8579 (cid, ri, next, fetch_rekey_chunks(transport, relays, &group).await)
8580 })
8581 .buffer_unordered(CHANNEL_FAN_CONCURRENCY);
8582 while let Some((cid, ri, next, out)) = results.next().await {
8583 let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
8584 match out {
8585 Ok(chunks) => {
8586 if chunks.is_empty() {
8587 continue;
8588 }
8589 crate::log_debug!(
8590 "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
8591 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
8592 );
8593 prefetched.insert((cid.0, ri), chunks);
8594 }
8595 Err(e) => {
8596 crate::log_warn!(
8597 "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
8598 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
8599 );
8600 return Err(e);
8601 }
8602 }
8603 }
8604 }
8605
8606 for (cid, held_key, held_epoch, next) in fan {
8607 let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
8608 let mut batches: Vec<RekeyBatch> = Vec::new();
8609 for ri in 0..addressing_roots.len() {
8610 if let Some(chunks) = prefetched.remove(&(cid.0, ri)) {
8611 batches.push((chunks, held_key.map(|k| (held_epoch, k))));
8612 }
8613 }
8614 match advance_scope(&batches, RekeyScope::Channel(cid), cur.id(), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
8621 Advance::Adopt { new_key, .. } => {
8622 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
8623 ch.key = Some(new_key);
8624 ch.epoch = next;
8625 }
8626 crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
8627 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) {
8632 crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
8633 }
8634 advanced = true;
8635 changed = true;
8636 }
8637 Advance::Removed => {
8638 match held_key {
8639 Some(_) => {
8641 cur.channels.retain(|c| c.id.0 != cid.0);
8642 }
8643 None => {
8648 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
8649 ch.epoch = next;
8650 }
8651 }
8652 }
8653 advanced = true;
8654 changed = true;
8655 }
8656 Advance::Stay => {}
8657 Advance::StayWedged => {
8658 wedged = true;
8662 }
8663 }
8664 }
8665
8666 {
8670 let held_epoch = cur.root_epoch;
8671 let held_key = cur.community_root;
8672 let next = Epoch(held_epoch.0.saturating_add(1));
8673 let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
8674 let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
8675 let batches = vec![(chunks, Some((held_epoch, held_key)))];
8676 match advance_scope(&batches, RekeyScope::Root, cur.id(), &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
8677 Advance::Adopt { new_key, control_pk, control_root, severed, rotator } => {
8678 record_epoch_minter(&cid_hex, next.0, &rotator.to_hex());
8683 if severed {
8688 severed_hops.push(next.0);
8689 }
8690 cur.community_root = new_key;
8691 cur.root_epoch = next;
8692 if cur.control_pk.is_some() && control_pk.is_none() {
8704 crate::log_warn!(
8705 "[v2:follow {}] rotation to epoch {} carried a LEGACY base blob — this community's control plane reverts to the member-writable address",
8706 &cid_hex[..8.min(cid_hex.len())], next.0
8707 );
8708 }
8709 cur.control_pk = control_pk.and_then(|pk| PublicKey::from_slice(&pk).ok());
8710 cur.control_root = control_root;
8711 if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
8715 crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
8716 }
8717 match crate::db::community::community_protocol(community.id()) {
8735 Ok(Some(_)) => {
8736 if let Err(e) = crate::db::community::save_community_v2(&cur) {
8737 crate::log_warn!("v2: hop checkpoint failed at epoch {} (the walk will redo it): {e}", next.0);
8738 }
8739 }
8740 Ok(None) => return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged }),
8742 Err(e) => crate::log_warn!("v2: hop checkpoint skipped at epoch {}: {e}", next.0),
8743 }
8744 advanced = true;
8745 changed = true;
8746 }
8747 Advance::Removed => {
8748 return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false, wedged: false });
8749 }
8750 Advance::Stay => {}
8751 Advance::StayWedged => {
8752 wedged = true;
8756 }
8757 }
8758 }
8759
8760 if !advanced {
8761 break;
8762 }
8763 }
8764
8765 if wedged {
8766 crate::log_warn!(
8770 "[v2:follow {}] a complete rotation delivered a key blob this client cannot open — parked at epoch {} (never treated as a removal)",
8771 &cid_hex[..8.min(cid_hex.len())], cur.root_epoch.0
8772 );
8773 crate::emit_event(
8774 "community_rekey_wedged",
8775 &serde_json::json!({ "community_id": cid_hex, "held_epoch": cur.root_epoch.0 }),
8776 );
8777 }
8778 if !changed {
8779 retry_pending_severance(transport, &cur).await;
8784 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged });
8785 }
8786 if crate::db::community::community_protocol(community.id())?.is_none() {
8789 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged });
8790 }
8791 crate::db::community::save_community_v2(&cur)?;
8792 for hop in &severed_hops {
8797 add_sever_mark(&cid_hex, *hop, now_ms() / 1000);
8798 }
8799 if !severed_hops.is_empty() {
8800 if let Err(e) = sever_own_links(transport, &cur).await {
8805 crate::log_warn!("[Sever] own-link revocation on adopt failed (retries next follow): {e}");
8806 }
8807 }
8808 let _ = refresh_public_links(transport, &cur).await;
8815 retry_pending_severance(transport, &cur).await;
8818 Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false, wedged })
8819 })
8820 .await
8821}
8822
8823enum Advance {
8826 Adopt { new_key: [u8; 32], control_pk: Option<[u8; 32]>, control_root: Option<[u8; 32]>, severed: bool, rotator: PublicKey },
8833 Removed,
8835 Stay,
8837 StayWedged,
8841}
8842
8843async fn fetch_rekey_chunks<T: Transport + ?Sized>(
8845 transport: &T,
8846 relays: &[String],
8847 group: &GroupKey,
8848) -> Result<Vec<rekey::RekeyChunk>, String> {
8849 const REKEY_PAGE: usize = 200;
8857 const REKEY_MAX_PAGES: usize = 6;
8858 let mut out = Vec::new();
8859 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
8860 let mut until: Option<u64> = None;
8861 let mut oldest: Option<u64> = None;
8862 for _ in 0..REKEY_MAX_PAGES {
8863 let query = Query {
8864 kinds: vec![stream::KIND_WRAP],
8865 authors: vec![group.pk_hex()],
8866 until,
8867 limit: Some(REKEY_PAGE),
8868 ..Default::default()
8869 };
8870 let page = transport.fetch_plane(group.keys(), &query, relays).await?;
8875 let page_complete = page.is_complete();
8880 let wraps = page.events;
8881 let mut fresh = 0usize;
8882 for w in &wraps {
8883 if !seen.insert(w.id) {
8884 continue;
8885 }
8886 fresh += 1;
8887 let at = w.created_at.as_secs();
8888 if oldest.is_none_or(|o| at < o) {
8889 oldest = Some(at);
8890 }
8891 if let Ok(opened) = stream::open_wrap(w, group) {
8892 if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
8893 out.push(chunk);
8894 }
8895 }
8896 }
8897 if fresh == 0 || (wraps.len() < REKEY_PAGE && page_complete) {
8900 break;
8901 }
8902 match oldest {
8903 Some(o) if o > 0 => until = Some(o),
8904 _ => break,
8905 }
8906 }
8907 Ok(out)
8908}
8909
8910type RekeyBatch = (Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>);
8912
8913#[allow(clippy::too_many_arguments)]
8927async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
8928 batches: &[RekeyBatch],
8929 scope: RekeyScope,
8930 community_id: &crate::community::CommunityId,
8931 rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
8932 rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
8933 admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
8934 signer: &S,
8935 my_xonly: &[u8; 32],
8936 next_epoch: Epoch,
8937) -> Advance {
8938 let mut winners: Vec<rekey::BaseKeyDelivery> = Vec::new();
8939 let mut winner_severed: Vec<bool> = Vec::new();
8943 let mut winner_rotator: Vec<PublicKey> = Vec::new();
8946 let mut saw_complete_candidate = false;
8947 let mut saw_nonempty_candidate = false;
8949 let mut saw_outranking_candidate = false;
8950 let mut my_blob_unopenable = false;
8951 let keyed = batches.iter().any(|(_, held)| held.is_some());
8952 for (chunks, held) in batches {
8953 let rotations = rekey::collect_rotations(chunks);
8954 for r in &rotations {
8955 if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 {
8956 continue;
8957 }
8958 if let Some((held_epoch, held_key)) = held {
8959 if r.continuity(*held_epoch, held_key) != Continuity::Extends {
8960 continue;
8961 }
8962 }
8963 if !admissible(r) {
8969 continue;
8970 }
8971 if r.is_complete() {
8987 saw_complete_candidate = true;
8988 saw_nonempty_candidate |= !r.blobs.is_empty();
8989 saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
8990 }
8991 let mut mine = rekey::find_my_blobs(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch).peekable();
8996 let had_candidate = mine.peek().is_some();
8997 let mut opened_one = false;
8998 for blob in mine {
8999 let opened = match scope {
9000 RekeyScope::Root => rekey::open_base_blob(signer, &r.rotator, community_id, r.new_epoch, blob).await,
9002 RekeyScope::Channel(_) => rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob)
9003 .await
9004 .map(|k| rekey::BaseKeyDelivery { new_root: k, control_pk: None, control_root: None }),
9005 };
9006 if let Ok(d) = opened {
9007 winners.push(d);
9008 winner_severed.push(scope.id32() == RekeyScope::Root.id32() && r.severed);
9009 winner_rotator.push(r.rotator);
9010 opened_one = true;
9011 break;
9012 }
9013 }
9014 if had_candidate && !opened_one {
9019 my_blob_unopenable = true;
9020 }
9021 }
9022 }
9023 if !winners.is_empty() {
9024 let keys: Vec<[u8; 32]> = winners.iter().map(|d| d.new_root).collect();
9033 let idx = rekey::lowest_key_winner(&keys).expect("winners is non-empty");
9034 let w = &winners[idx];
9035 return Advance::Adopt {
9036 new_key: w.new_root,
9037 control_pk: w.control_pk,
9038 control_root: w.control_root,
9039 severed: winner_severed[idx],
9040 rotator: winner_rotator[idx],
9041 };
9042 }
9043 if saw_complete_candidate && !saw_nonempty_candidate {
9050 return Advance::Stay;
9051 }
9052 if saw_complete_candidate && !my_blob_unopenable && (!keyed || saw_outranking_candidate) {
9053 Advance::Removed
9054 } else if my_blob_unopenable {
9055 Advance::StayWedged
9056 } else {
9057 Advance::Stay
9058 }
9059}
9060
9061#[derive(Debug, serde::Serialize)]
9065pub struct ChannelPins {
9066 pub pins: Vec<super::pins::VerifiedPin>,
9068 pub sealed: bool,
9072 pub version: u64,
9074}
9075
9076fn channel_conv_key_at(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Option<[u8; 32]> {
9080 let ikm = channel_conv_ikm(community, ch, epoch).ok()?;
9081 if ch.private && ikm == community.community_root {
9084 return None;
9085 }
9086 let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
9087 group.conv_key().as_bytes().try_into().ok()
9088}
9089
9090pub fn read_channel_pins(community: &CommunityV2, channel_id: &ChannelId) -> Result<ChannelPins, String> {
9094 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
9095 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9096 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
9097 let Some((content, version)) = crate::db::community::get_community_pins(&cid_hex, &ch_hex)? else {
9098 return Ok(ChannelPins { pins: Vec::new(), sealed: false, version: 0 });
9099 };
9100 let read = super::pins::read_pin_list(&content, |epoch| channel_conv_key_at(community, ch, epoch));
9101 let pins = read
9102 .entries
9103 .iter()
9104 .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
9105 .collect();
9106 Ok(ChannelPins { pins, sealed: read.sealed, version: version.max(0) as u64 })
9107}
9108
9109async fn publish_pin_list<T: Transport + ?Sized>(
9113 transport: &T,
9114 community: &CommunityV2,
9115 ch: &ChannelV2,
9116 entries: &[super::pins::PinEntry],
9117) -> Result<(), String> {
9118 crate::db::scoped(async move {
9119 let content = if ch.private {
9120 let key = ch.key.ok_or("this private channel's key has not arrived yet")?;
9121 let group = channel_group_key(&key, &ch.id, ch.epoch);
9122 super::pins::serialize_sealed_pin_list(entries, group.conv_key(), ch.epoch.0)?
9123 } else {
9124 super::pins::serialize_public_pin_list(entries)?
9125 };
9126 let eid = super::derive::pins_locator(community.id(), &ch.id);
9127 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9128 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
9129 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
9130 let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
9132 Some((v, _)) => v + 1,
9133 None => 1,
9134 };
9135 crate::log_info!("[pins] publishing v{} with {} entries for channel {}", version, entries.len(), &ch_hex[..12]);
9136 publish_control_edition(transport, community, vsk::PINS, &eid, &content).await?;
9137 let _ = crate::db::community::set_community_pins(&cid_hex, &ch_hex, &content, version as i64);
9138 crate::emit_event(
9139 "community_pins_updated",
9140 &serde_json::json!({ "community_id": cid_hex, "channel_id": ch_hex }),
9141 );
9142 if let Ok(me) = me_pk() {
9143 use nostr_sdk::prelude::ToBech32;
9144 let me_npub = me.to_bech32().unwrap_or_else(|_| me.to_hex());
9145 note_pins_modified(&ch_hex, version, &me_npub, now_ms() / 1000).await;
9146 }
9147 Ok(())
9148 })
9149 .await
9150}
9151
9152async fn note_pins_modified(channel_hex: &str, version: u64, actor_npub: &str, at_secs: u64) {
9157 let event_id = format!("pins-mod-{}-v{}", &channel_hex[..16], version);
9158 let inserted = crate::db::events::save_system_event_at(
9159 &event_id,
9160 channel_hex,
9161 crate::stored_event::SystemEventType::PinsModified,
9162 actor_npub,
9163 None,
9164 at_secs,
9165 None,
9166 None,
9167 )
9168 .await
9169 .unwrap_or(false);
9170 if inserted {
9171 crate::emit_event(
9172 "system_event",
9173 &serde_json::json!({
9174 "conversation_id": channel_hex,
9175 "event_id": event_id,
9176 "event_type": crate::stored_event::SystemEventType::PinsModified.as_u8(),
9177 "member_pubkey": actor_npub,
9178 }),
9179 );
9180 }
9181}
9182
9183fn writable_pin_entries(community: &CommunityV2, channel_id: &ChannelId) -> Result<Vec<super::pins::PinEntry>, String> {
9187 let current = read_channel_pins(community, channel_id)?;
9188 if current.sealed {
9189 return Err("this channel's pins are sealed under a key you don't hold; pinning would erase them".to_string());
9190 }
9191 Ok(current.pins.into_iter().map(|p| p.entry).collect())
9192}
9193
9194pub async fn pin_message<T: Transport + ?Sized>(
9199 transport: &T,
9200 community: &CommunityV2,
9201 channel_id: &ChannelId,
9202 rumor_id_hex: &str,
9203) -> Result<(), String> {
9204 crate::db::scoped(async move {
9205 let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
9206 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
9207
9208 let mut entries = writable_pin_entries(community, channel_id)?;
9209 if entries.len() >= super::pins::PIN_MAX_ENTRIES {
9210 return Err(format!("this channel already holds {} pins; unpin one first", super::pins::PIN_MAX_ENTRIES));
9211 }
9212 if entries
9214 .iter()
9215 .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
9216 .any(|v| v.rumor_id == rumor_id_hex)
9217 {
9218 return Ok(());
9219 }
9220
9221 let (wrap_id, _tags) = crate::db::events::get_event_wrap_context(rumor_id_hex)?
9222 .ok_or("message not found in this device's history")?;
9223 let wrap_id = wrap_id.ok_or("this message's original wrap id was not recorded")?;
9224
9225 let wraps = transport
9228 .fetch(
9229 &Query {
9230 ids: vec![wrap_id.clone()],
9231 kinds: vec![super::stream::KIND_WRAP],
9232 limit: Some(1),
9233 evidence: crate::community::transport::Evidence::Full,
9234 ..Default::default()
9235 },
9236 &community.relays,
9237 )
9238 .await?;
9239 let wrap = wraps
9240 .iter()
9241 .find(|w| w.id.to_hex() == wrap_id)
9242 .ok_or("the message's wrap is no longer served by this community's relays")?;
9243
9244 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9250 let mut coords: Vec<(u64, [u8; 32])> = Vec::new();
9251 if ch.private {
9252 if let Some(k) = ch.key {
9253 coords.push((ch.epoch.0, k));
9254 }
9255 let held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
9256 coords.extend(held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (ep.0, k)));
9257 } else {
9258 coords.push((community.root_epoch.0, community.community_root));
9259 let held = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
9260 coords.extend(held.into_iter().map(|(ep, k)| (ep.0, k)));
9261 }
9262 coords.dedup();
9263
9264 let mut found: Option<(super::stream::OpenedStream, [u8; 32])> = None;
9265 for (epoch, ikm) in coords {
9266 let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
9267 if let Ok(super::chat::ChatEvent::Message { opened, .. }) =
9268 super::chat::open_chat_event(wrap, &group, channel_id, Epoch(epoch))
9269 {
9270 if opened.rumor_id.to_hex() == rumor_id_hex {
9271 let conv: [u8; 32] = group
9272 .conv_key()
9273 .as_bytes()
9274 .try_into()
9275 .map_err(|_| "conversation key size".to_string())?;
9276 found = Some((opened, conv));
9277 break;
9278 }
9279 }
9280 }
9281 let Some((opened, conv_key)) = found else {
9282 return Err("that message is from a key epoch this device no longer holds".to_string());
9283 };
9284
9285 let entry = super::pins::build_pin_entry(&opened, &conv_key, &ch_hex).map_err(|e| match e {
9286 super::pins::PinBuildFailure::NotEncrypted => "this message's seal form cannot be pinned".to_string(),
9287 super::pins::PinBuildFailure::BadPayload => "that message is from a key epoch this device no longer holds".to_string(),
9288 super::pins::PinBuildFailure::Unverifiable => "this message's proof did not verify".to_string(),
9289 })?;
9290 entries.push(entry);
9291
9292 publish_pin_list(transport, community, &ch, &entries).await
9293 })
9294 .await
9295}
9296
9297fn channel_conv_ikm(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Result<[u8; 32], String> {
9300 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9301 if ch.private {
9302 if ch.epoch.0 == epoch {
9303 return ch.key.ok_or("this private channel's key has not arrived yet".to_string());
9304 }
9305 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
9306 crate::db::community::held_epoch_keys(&cid_hex, &ch_hex)
9307 .unwrap_or_default()
9308 .into_iter()
9309 .find(|(ep, _)| ep.0 == epoch)
9310 .map(|(_, k)| k)
9311 .ok_or("that message is from a key epoch this device no longer holds".to_string())
9312 } else if community.root_epoch.0 == epoch {
9313 Ok(community.community_root)
9314 } else {
9315 crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
9316 .unwrap_or_default()
9317 .into_iter()
9318 .find(|(ep, _)| ep.0 == epoch)
9319 .map(|(_, k)| k)
9320 .ok_or("that message is from a root epoch this device no longer holds".to_string())
9321 }
9322}
9323
9324pub async fn unpin_message<T: Transport + ?Sized>(
9326 transport: &T,
9327 community: &CommunityV2,
9328 channel_id: &ChannelId,
9329 rumor_id_hex: &str,
9330) -> Result<(), String> {
9331 let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
9332 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
9333 let entries = writable_pin_entries(community, channel_id)?;
9334 let kept: Vec<super::pins::PinEntry> = entries
9335 .into_iter()
9336 .filter(|e| {
9337 super::pins::verify_pin_entry(e, &ch_hex)
9338 .map(|v| v.rumor_id != rumor_id_hex)
9339 .unwrap_or(true)
9342 })
9343 .collect();
9344 publish_pin_list(transport, community, &ch, &kept).await
9345}
9346
9347pub(crate) fn spawn_pin_duty(channel_hex: &str, target_rumor_hex: &str, edit: Option<super::stream::OpenedStream>) {
9351 let channel_hex = channel_hex.to_string();
9352 let target = target_rumor_hex.to_string();
9353 crate::db::spawn_bound(async move {
9354 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
9355 let _ = run_pin_duty(&transport, &channel_hex, &target, edit).await;
9356 });
9357}
9358
9359async fn run_pin_duty<T: Transport + ?Sized>(
9366 transport: &T,
9367 channel_hex: &str,
9368 target: &str,
9369 edit: Option<super::stream::OpenedStream>,
9370) -> Result<(), String> {
9371 use crate::community::roles::Permissions;
9372 let Some(cid_hex) = crate::db::community::community_id_for_channel(channel_hex)? else {
9373 return Ok(());
9374 };
9375 let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
9376 let Some(community) = crate::db::community::load_community_v2(&cid)? else {
9377 return Ok(());
9378 };
9379 let channel_id = ChannelId(crate::simd::hex::hex_to_bytes_32(channel_hex));
9380
9381 let read = read_channel_pins(&community, &channel_id)?;
9383 if read.sealed {
9384 return Ok(());
9385 }
9386 let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
9387 return Ok(());
9388 };
9389 if let Some(ed) = &edit {
9390 if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
9392 return Ok(());
9393 }
9394 }
9395 let me = me_pk()?;
9396 let me_hex = me.to_hex();
9397 let owner_hex = community.owner()?.to_hex();
9398 let roster = crate::db::community::get_community_roles(&cid_hex)?;
9399 if !roster.is_authorized(&me_hex, Some(&owner_hex), Permissions::PIN_MESSAGES) {
9400 return Ok(());
9401 }
9402
9403 if hit.author != me_hex {
9404 let mut h: u32 = 0;
9405 for b in me_hex.bytes().chain(target.bytes()) {
9406 h = h.wrapping_mul(31).wrapping_add(u32::from(b));
9407 }
9408 crate::rt::time::sleep(std::time::Duration::from_secs(5 + u64::from(h % 21))).await;
9409 }
9410
9411 let read = read_channel_pins(&community, &channel_id)?;
9413 if read.sealed {
9414 return Ok(());
9415 }
9416 let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
9417 return Ok(());
9418 };
9419 let ch = community.channel(&channel_id).ok_or("no such channel")?.clone();
9420
9421 let entries: Vec<super::pins::PinEntry> = match &edit {
9422 None => read
9424 .pins
9425 .iter()
9426 .filter(|p| p.rumor_id != target)
9427 .map(|p| p.entry.clone())
9428 .collect(),
9429 Some(ed) => {
9431 if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
9432 return Ok(());
9433 }
9434 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
9435 let mut bundle = None;
9438 let mut epochs: Vec<u64> = vec![if ch.private { ch.epoch.0 } else { community.root_epoch.0 }];
9439 let scope = if ch.private { ch_hex.clone() } else { crate::community::SERVER_ROOT_SCOPE_HEX.to_string() };
9440 epochs.extend(
9441 crate::db::community::held_epoch_keys(&cid_hex, &scope)
9442 .unwrap_or_default()
9443 .into_iter()
9444 .map(|(ep, _)| ep.0),
9445 );
9446 epochs.dedup();
9447 for epoch in epochs {
9448 let Some(conv) = channel_conv_key_at(&community, &ch, epoch) else { continue };
9449 if let Ok(b) = super::pins::build_pin_edit_bundle(ed, &conv, &hit.author, target, &ch_hex) {
9450 bundle = Some(b);
9451 break;
9452 }
9453 }
9454 let Some(bundle) = bundle else { return Ok(()) };
9455 read.pins
9456 .iter()
9457 .map(|p| {
9458 let mut entry = p.entry.clone();
9459 if p.rumor_id == target {
9460 entry.edit = Some(bundle.clone());
9461 }
9462 entry
9463 })
9464 .collect()
9465 }
9466 };
9467
9468 crate::log_info!(
9469 "[pins] duty {} for target {} in channel {}",
9470 if edit.is_some() { "edit-refresh" } else { "omission" },
9471 &target[..12],
9472 &channel_hex[..12]
9473 );
9474 publish_pin_list(transport, &community, &ch, &entries).await
9475}
9476
9477pub async fn upgrade_admin_role_pin_bit<T: Transport + ?Sized>(
9482 transport: &T,
9483 community: &CommunityV2,
9484) -> Result<bool, String> {
9485 use crate::community::roles::{Permissions, RoleScope};
9486 let my_pk = me_pk()?;
9487 if community.owner()? != my_pk {
9488 return Ok(false);
9489 }
9490 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9491 let roles = crate::db::community::get_community_roles(&cid_hex)?;
9492 let Some(role) = roles.roles.iter().find(|r| {
9493 matches!(r.scope, RoleScope::Server)
9494 && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK)
9495 && !r.permissions.contains(Permissions::PIN_MESSAGES)
9496 }) else {
9497 return Ok(false);
9498 };
9499 let mut widened = role.clone();
9500 widened.permissions.0 |= Permissions::PIN_MESSAGES;
9501 set_role(transport, community, &widened).await?;
9502 Ok(true)
9503}
9504
9505#[cfg(test)]
9506mod tests {
9507 use super::super::derive::control_group_key;
9510 use crate::ClientRelayExt;
9511 use nostr_sdk::prelude::FinalizeEvent;
9512 use super::super::super::transport::memory::MemoryRelay;
9513 use super::*;
9514 use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
9515
9516 fn account_name(n: u32) -> String {
9518 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
9519 let mut acct = String::from("npub1");
9520 let mut v = n as usize;
9521 for _ in 0..58 {
9522 acct.push(B[v % 32] as char);
9523 v = v / 32 + 7;
9524 }
9525 acct
9526 }
9527
9528 struct Actor {
9530 keys: Keys,
9531 account: String,
9532 }
9533
9534 struct TestBed {
9540 _tmp: tempfile::TempDir,
9541 _guard: std::sync::MutexGuard<'static, ()>,
9542 relay: MemoryRelay,
9543 relays: Vec<String>,
9544 }
9545
9546 impl TestBed {
9547 fn new() -> (TestBed, Actor, Actor) {
9548 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
9549 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
9550 crate::db::close_database();
9551 crate::db::clear_id_caches();
9552 let tmp = tempfile::tempdir().unwrap();
9553 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
9554
9555 let mk = || {
9556 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9557 let account = account_name(n);
9558 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
9559 crate::db::set_current_account(account.clone()).unwrap();
9560 crate::db::init_database(&account).unwrap();
9561 Actor { keys: Keys::generate(), account }
9562 };
9563 let owner = mk();
9564 let member = mk();
9565 let _ = crate::state::take_nostr_client();
9566 let bed = TestBed {
9567 _tmp: tmp,
9568 _guard: guard,
9569 relay: MemoryRelay::new(),
9570 relays: vec!["wss://r".to_string()],
9571 };
9572 (bed, owner, member)
9573 }
9574
9575 fn swap_to(&self, actor: &Actor) {
9580 crate::db::close_database();
9581 crate::db::set_current_account(actor.account.clone()).unwrap();
9582 crate::db::init_database(&actor.account).unwrap();
9583 crate::db::clear_id_caches();
9584 crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
9585 crate::state::set_my_public_key(actor.keys.public_key());
9586 }
9587 }
9588
9589 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
9591 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
9592 crate::db::close_database();
9593 crate::db::clear_id_caches();
9594 let tmp = tempfile::tempdir().unwrap();
9595 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
9596 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9597 let acct = account_name(n);
9598 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
9599 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
9600 crate::db::set_current_account(acct.clone()).unwrap();
9601 crate::db::init_database(&acct).unwrap();
9602 let _ = crate::state::take_nostr_client();
9603 let owner = Keys::generate();
9604 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
9605 crate::state::set_my_public_key(owner.public_key());
9606 (tmp, guard, owner)
9607 }
9608
9609 struct SwapMidFetch<'a> {
9616 inner: &'a MemoryRelay,
9617 to: String,
9618 armed: std::sync::atomic::AtomicBool,
9619 }
9620
9621 impl<'a> SwapMidFetch<'a> {
9622 fn arm(inner: &'a MemoryRelay) -> Self {
9623 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(88_000);
9624 let to = account_name(N.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
9625 std::fs::create_dir_all(crate::db::shared_test_data_dir().join(&to)).unwrap();
9626 Self { inner, to, armed: std::sync::atomic::AtomicBool::new(true) }
9627 }
9628 }
9629
9630 #[async_trait::async_trait]
9631 impl Transport for SwapMidFetch<'_> {
9632 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
9633 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
9634 self.inner.publish(e, r).await
9635 }
9636 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
9637 self.inner.publish_durable(e, r).await
9638 }
9639 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
9640 let out = self.inner.fetch(q, r).await;
9641 if self.armed.swap(false, std::sync::atomic::Ordering::SeqCst) {
9642 crate::db::set_current_account(self.to.clone()).unwrap();
9643 crate::db::init_database(&self.to).unwrap();
9644 }
9645 out
9646 }
9647 }
9648
9649 struct SwapMidPublish {
9652 inner: MemoryRelay,
9653 }
9654 #[async_trait::async_trait]
9655 impl Transport for SwapMidPublish {
9656 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
9657 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
9658 self.inner.publish(e, r).await
9659 }
9660 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
9661 let out = self.inner.publish_durable(e, r).await;
9662 crate::db::close_database();
9663 out
9664 }
9665 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
9666 self.inner.fetch(q, r).await
9667 }
9668 }
9669
9670 struct FixedFetch {
9675 events: Vec<Event>,
9676 }
9677 #[async_trait::async_trait]
9678 impl Transport for FixedFetch {
9679 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
9680 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9681 Ok(())
9682 }
9683 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9684 Ok(())
9685 }
9686 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
9687 Ok(self.events.clone())
9688 }
9689 }
9690
9691 async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
9694 let q = Query {
9695 kinds: vec![stream::KIND_WRAP],
9696 p_tags: vec![me.to_hex()],
9697 k_tags: vec!["3313".to_string()],
9698 ..Default::default()
9699 };
9700 relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
9701 }
9702
9703 #[tokio::test]
9704 async fn create_persists_and_reloads_a_v2_community() {
9705 let (_tmp, _guard, owner) = init_test_db();
9706 let relay = MemoryRelay::new();
9707 let relays = vec!["wss://r".to_string()];
9708
9709 let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
9710 assert!(created.identity.verify());
9711 assert_eq!(created.owner().unwrap(), owner.public_key());
9712 assert_eq!(created.channels.len(), 1);
9713
9714 assert_eq!(
9716 crate::db::community::community_protocol(created.id()).unwrap(),
9717 Some(crate::community::ConcordProtocol::V2)
9718 );
9719 let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
9720 assert_eq!(loaded.name, "Vectorville");
9721 assert_eq!(loaded.community_root, created.community_root);
9722 assert_eq!(loaded.identity, created.identity);
9723 assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
9724 assert!(!loaded.channels[0].private);
9725
9726 assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
9728 }
9729
9730 #[tokio::test]
9731 async fn owner_sends_and_reads_back_a_message() {
9732 let (_tmp, _guard, _owner) = init_test_db();
9733 let relay = MemoryRelay::new();
9734 let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
9735 let general = community.channels[0].id;
9736
9737 let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
9738 let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
9739 assert_ne!(id1, id2);
9740
9741 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
9742 let texts: Vec<String> = page
9743 .iter()
9744 .filter_map(|f| match &f.event {
9745 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
9746 _ => None,
9747 })
9748 .collect();
9749 assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
9750 }
9751
9752 #[test]
9753 fn send_stamps_never_repeat_within_a_process() {
9754 let stamps: Vec<u64> = (0..5_000).map(|_| next_send_ms()).collect();
9762 for pair in stamps.windows(2) {
9763 assert!(pair[1] > pair[0], "stamps must strictly increase: {} then {}", pair[0], pair[1]);
9764 }
9765 }
9766
9767 #[tokio::test]
9768 async fn same_millisecond_messages_order_by_rumor_id_not_relay_order() {
9769 let (_tmp, _guard, _owner) = init_test_db();
9775 let relay = MemoryRelay::new();
9776 let community = create_community(&relay, "Ties", vec!["wss://r".into()], None).await.unwrap();
9777 let general = community.channels[0].id;
9778
9779 let at = now_ms();
9780 for i in 0..6 {
9781 send_chat_message_at(&relay, &community, &general, &format!("tied {i}"), None, &[], vec![], at)
9782 .await
9783 .unwrap();
9784 }
9785 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
9786 let msgs: Vec<(Vec<u8>, String)> = page
9787 .iter()
9788 .filter_map(|f| match &f.event {
9789 ChatEvent::Message { .. } => {
9790 let o = f.event.opened();
9791 Some((o.rumor_id.as_bytes().to_vec(), o.rumor.content.clone()))
9792 }
9793 _ => None,
9794 })
9795 .collect();
9796 assert_eq!(msgs.len(), 6, "all six tied messages read back");
9797 let got: Vec<String> = msgs.iter().map(|(_, c)| c.clone()).collect();
9798 let mut by_id = msgs.clone();
9799 by_id.sort_by(|a, b| a.0.cmp(&b.0));
9800 let expected: Vec<String> = by_id.into_iter().map(|(_, c)| c).collect();
9801 assert_eq!(got, expected, "ties resolve by rumor id, never by the relay's serving order");
9802 }
9803
9804 #[tokio::test]
9805 async fn a_rapid_burst_carries_strictly_increasing_stamps() {
9806 let (_tmp, _guard, _owner) = init_test_db();
9811 let relay = MemoryRelay::new();
9812 let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
9813 let general = community.channels[0].id;
9814 for i in 0..8 {
9815 send_message(&relay, &community, &general, &format!("msg {i}")).await.unwrap();
9816 }
9817 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
9818 let stamps: Vec<u64> = page
9819 .iter()
9820 .filter_map(|f| match &f.event {
9821 ChatEvent::Message { .. } => Some(f.event.opened().at_ms),
9822 _ => None,
9823 })
9824 .collect();
9825 assert_eq!(stamps.len(), 8);
9826 for pair in stamps.windows(2) {
9827 assert!(pair[1] > pair[0], "a sender's own stamps must strictly increase, got {pair:?}");
9828 }
9829 }
9830
9831 #[tokio::test]
9832 async fn a_second_member_reads_the_public_channel_from_the_root() {
9833 let (_tmp, _guard, _owner) = init_test_db();
9837 let relay = MemoryRelay::new();
9838 let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
9839 let general = community.channels[0].id;
9840 send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
9841
9842 let member_view = community.clone();
9844 let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
9845 assert_eq!(page.len(), 1);
9846 assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
9847 assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
9848 }
9849
9850 async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
9853 fetch_channel(relay, community, channel, 100)
9854 .await
9855 .unwrap()
9856 .iter()
9857 .filter_map(|f| match &f.event {
9858 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
9859 _ => None,
9860 })
9861 .collect()
9862 }
9863
9864 async fn all_texts_in<T: crate::community::transport::Transport + ?Sized>(
9868 relay: &T,
9869 community: &CommunityV2,
9870 channel: &ChannelId,
9871 ) -> Vec<String> {
9872 let from_now = now_ms() / 1000 + 3600;
9873 fetch_channel_history(
9874 relay,
9875 community,
9876 channel,
9877 100,
9878 8,
9879 None,
9880 Some(from_now),
9881 crate::community::transport::Evidence::Quorum,
9882 |_| true,
9883 )
9884 .await
9885 .unwrap()
9886 .iter()
9887 .filter_map(|f| match &f.event {
9888 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
9889 _ => None,
9890 })
9891 .collect()
9892 }
9893
9894 #[tokio::test]
9895 async fn direct_invite_full_loop_owner_and_member_converse() {
9896 let (bed, owner, member) = TestBed::new();
9897
9898 bed.swap_to(&owner);
9900 let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
9901 let general = community.channels[0].id;
9902 send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
9903 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
9904
9905 bed.swap_to(&member);
9907 assert!(
9908 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
9909 "the member does not hold the community before joining"
9910 );
9911 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9912 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
9913 assert_eq!(joined.id().0, community.id().0, "joined the same community");
9914 assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
9915 assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
9916
9917 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
9919 send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
9920
9921 bed.swap_to(&owner);
9923 assert_eq!(
9924 texts_in(&bed.relay, &community, &general).await,
9925 vec!["owner: welcome!", "member: thanks for the invite"],
9926 "both actors' messages interleave in ms order on the shared channel"
9927 );
9928
9929 let members = memberlist(&bed.relay, &community).await.unwrap();
9931 assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
9932 assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
9933 assert_eq!(members.len(), 2);
9934 }
9935
9936 #[tokio::test]
9940 async fn a_banned_member_is_refused_at_join_time() {
9941 let (bed, owner, member) = TestBed::new();
9942
9943 bed.swap_to(&owner);
9944 let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
9945 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
9946 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
9947
9948 bed.swap_to(&member);
9949 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9950 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
9951 assert!(err.contains("banned"), "refusal names the reason: {err}");
9952 assert!(
9953 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
9954 "a refused join persists nothing"
9955 );
9956
9957 bed.swap_to(&owner);
9960 set_banlist(&bed.relay, &community, &[]).await.unwrap();
9961 bed.swap_to(&member);
9962 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
9963 assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
9964 }
9965
9966 #[tokio::test]
9970 async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
9971 use crate::community::migration;
9972 let (bed, owner, member) = TestBed::new();
9973
9974 bed.swap_to(&owner);
9976 let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
9977 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
9978 let jm = join_material(&v2);
9979
9980 bed.swap_to(&member);
9983 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9984 let v1_cid = v1.id.to_hex();
9985 v1.owner_attestation = Some({
9986 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9987 .finalize(&owner.keys).unwrap().as_json()
9988 });
9989 crate::db::community::save_community(&v1).unwrap();
9990 let v1_channel = v1.channels[0].id.to_hex();
9991
9992 let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
9994 let signpost = migration::MigrationSignpost {
9995 v2_community_id: v2_hex.clone(),
9996 owner_xonly: owner.keys.public_key().to_hex(),
9997 owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
9998 relays: bed.relays.clone(),
9999 name: "Guild".into(),
10000 primary_channel: v1_channel.clone(),
10001 root_epoch: 0,
10002 };
10003 let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
10004 crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
10005
10006 let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
10008 assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
10009
10010 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
10012 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
10013 assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
10014 let _ = v1_channel;
10015
10016 assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
10018 }
10019
10020 async fn held_twin_bed(
10025 relay: &MemoryRelay,
10026 bed: &TestBed,
10027 owner: &Actor,
10028 member: &Actor,
10029 ) -> (crate::community::Community, String, String, String, CommunityV2) {
10030 use crate::community::migration;
10031 bed.swap_to(owner);
10032 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10033 let v1_cid = v1.id.to_hex();
10034 let v1_channel = v1.channels[0].id.to_hex();
10035 v1.owner_attestation = Some({
10036 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10037 .finalize(&owner.keys).unwrap().as_json()
10038 });
10039 crate::db::community::save_community(&v1).unwrap();
10040 let v2_hex = migration::migrate_community_to_v2(relay, &v1, migration::MIGRATION_UNLOCK_AT + 1).await.unwrap();
10041 let v2_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex));
10042 let twin = crate::db::community::load_community_v2(&v2_id).unwrap().expect("owner holds the twin");
10043
10044 bed.swap_to(member);
10045 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10046 m_v1.id = v1.id;
10047 m_v1.server_root_key = v1.server_root_key.clone();
10048 m_v1.channels[0].id = v1.channels[0].id;
10049 m_v1.owner_attestation = v1.owner_attestation.clone();
10050 crate::db::community::save_community(&m_v1).unwrap();
10051 crate::db::community::save_community_v2(&twin).unwrap();
10052 assert_eq!(
10053 crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
10054 Some(v1_cid.as_str()),
10055 "precondition: the stitched channel is still v1-owned (channel-less twin)"
10056 );
10057 (m_v1, v1_cid, v1_channel, v2_hex, twin)
10058 }
10059
10060 fn signpost_for(twin: &CommunityV2, v2_hex: &str, owner_xonly: String, salt: [u8; 32], primary: &str, relays: Vec<String>) -> crate::community::migration::MigrationSignpost {
10061 let _ = twin;
10062 crate::community::migration::MigrationSignpost {
10063 v2_community_id: v2_hex.to_string(),
10064 owner_xonly,
10065 owner_salt: crate::simd::hex::bytes_to_hex_32(&salt),
10066 relays,
10067 name: "Guild".into(),
10068 primary_channel: primary.to_string(),
10069 root_epoch: 0,
10070 }
10071 }
10072
10073 #[tokio::test]
10078 async fn a_held_twin_flips_without_opening_m() {
10079 use crate::community::migration;
10080 let (bed, owner, member) = TestBed::new();
10081 let (m_v1, v1_cid, v1_channel, v2_hex, twin) = held_twin_bed(&bed.relay, &bed, &owner, &member).await;
10082 let v2_id = twin.identity.community_id;
10083
10084 let m = migration::seal_m(&[7u8; 32], &serde_json::to_vec(&join_material(&twin)).unwrap()).unwrap();
10086 let signpost = signpost_for(&twin, &v2_hex, owner.keys.public_key().to_hex(), twin.identity.owner_salt, &v1_channel, bed.relays.clone());
10087 crate::db::community::set_migration_pointer(&v1_cid, &migration::build_migration_content(&signpost, Some(m)).unwrap()).unwrap();
10088
10089 let published_before = bed.relay.stored_count();
10090 let roots_before = crate::db::community::held_epoch_keys(&v2_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
10091
10092 let flipped = migration::drive_migration(&bed.relay, &m_v1).await.unwrap();
10093 assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "a held twin flips even though m cannot open");
10094
10095 assert_eq!(
10096 crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
10097 Some(v2_hex.as_str()),
10098 "the stitched channel moved to the twin"
10099 );
10100 assert!(
10101 crate::db::community::load_community_v2(&v2_id).unwrap().unwrap().channels.iter().any(|c| c.id.0 == m_v1.channels[0].id.0),
10102 "the twin now lists the channel (what a member actually sees)"
10103 );
10104 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
10105 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "fence layer 0 set");
10106 assert_eq!(bed.relay.stored_count(), published_before, "a held flip publishes nothing");
10107 assert_eq!(
10108 crate::db::community::held_epoch_keys(&v2_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(),
10109 roots_before,
10110 "a held flip touches no keys"
10111 );
10112 assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None, "idempotent");
10113 }
10114
10115 #[tokio::test]
10121 async fn a_held_twin_still_refuses_an_owner_discontinuity() {
10122 use crate::community::migration;
10123 let (bed, owner, member) = TestBed::new();
10124 let (m_v1, v1_cid, v1_channel, _v2_hex, _twin) = held_twin_bed(&bed.relay, &bed, &owner, &member).await;
10125
10126 let mine = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
10128 let mine_hex = crate::simd::hex::bytes_to_hex_32(&mine.identity.community_id.0);
10129 let signpost = signpost_for(&mine, &mine_hex, member.keys.public_key().to_hex(), mine.identity.owner_salt, &v1_channel, bed.relays.clone());
10130 crate::db::community::set_migration_pointer(&v1_cid, &migration::build_migration_content(&signpost, None).unwrap()).unwrap();
10131
10132 assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None, "refused");
10133 assert_eq!(
10134 crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
10135 Some(v1_cid.as_str()),
10136 "nothing moved"
10137 );
10138 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no fence");
10139 }
10140
10141 #[tokio::test]
10145 async fn a_held_twin_still_refuses_a_forged_self_cert() {
10146 use crate::community::migration;
10147 let (bed, owner, member) = TestBed::new();
10148 let (m_v1, v1_cid, v1_channel, v2_hex, twin) = held_twin_bed(&bed.relay, &bed, &owner, &member).await;
10149
10150 let signpost = signpost_for(&twin, &v2_hex, owner.keys.public_key().to_hex(), [9u8; 32], &v1_channel, bed.relays.clone());
10151 crate::db::community::set_migration_pointer(&v1_cid, &migration::build_migration_content(&signpost, None).unwrap()).unwrap();
10152
10153 assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None, "refused");
10154 assert_eq!(
10155 crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
10156 Some(v1_cid.as_str()),
10157 "nothing moved"
10158 );
10159 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no fence");
10160 }
10161
10162 #[tokio::test]
10166 async fn a_held_twin_flip_waits_for_an_in_flight_follow_pass() {
10167 use crate::community::migration;
10168 let (bed, owner, member) = TestBed::new();
10169 let relay = std::sync::Arc::new(MemoryRelay::new());
10170 let (m_v1, v1_cid, v1_channel, v2_hex, twin) = held_twin_bed(&relay, &bed, &owner, &member).await;
10171 let v2_id = twin.identity.community_id;
10172
10173 let signpost = signpost_for(&twin, &v2_hex, owner.keys.public_key().to_hex(), twin.identity.owner_salt, &v1_channel, bed.relays.clone());
10174 crate::db::community::set_migration_pointer(&v1_cid, &migration::build_migration_content(&signpost, None).unwrap()).unwrap();
10175
10176 let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
10177 let drive = crate::rt::spawn({
10178 let relay = relay.clone();
10179 let m_v1 = m_v1.clone();
10180 async move { migration::drive_migration(&*relay, &m_v1).await }
10181 });
10182
10183 crate::rt::time::sleep(std::time::Duration::from_millis(300)).await;
10184 assert!(!drive.is_finished(), "the flip must wait for the in-flight follow pass");
10185 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no fence while the lock is held");
10186 assert_eq!(
10187 crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
10188 Some(v1_cid.as_str()),
10189 "no reparent while the lock is held"
10190 );
10191
10192 drop(held);
10193 assert_eq!(drive.await.unwrap().unwrap().as_deref(), Some(v2_hex.as_str()), "the flip completes once released");
10194 assert_eq!(
10195 crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
10196 Some(v2_hex.as_str()),
10197 "the channel row is stitched to the twin"
10198 );
10199 }
10200
10201 #[tokio::test]
10206 async fn owner_wizard_then_member_migrate_and_stitch() {
10207 use crate::community::migration;
10208 let (bed, owner, member) = TestBed::new();
10209 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10210
10211 bed.swap_to(&owner);
10213 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10214 let v1_cid = v1.id.to_hex();
10215 let v1_channel = v1.channels[0].id.to_hex();
10216 v1.owner_attestation = Some({
10217 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10218 .finalize(&owner.keys).unwrap().as_json()
10219 });
10220 crate::db::community::save_community(&v1).unwrap();
10221
10222 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10224 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
10225 "owner's own client flipped to v2");
10226 assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
10229 "owner channel stitched to v2");
10230
10231 bed.swap_to(&member);
10233 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10234 m_v1.id = v1.id;
10236 m_v1.server_root_key = v1.server_root_key.clone();
10237 m_v1.channels[0].id = v1.channels[0].id;
10238 m_v1.owner_attestation = v1.owner_attestation.clone();
10239 crate::db::community::save_community(&m_v1).unwrap();
10240
10241 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
10244 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
10245 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
10246 "the FOLD ITSELF flipped the member (auto-drive)");
10247 assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
10248 "member holds the v2 twin");
10249 assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
10251 }
10252
10253 #[tokio::test]
10258 async fn wizard_publishes_the_twin_to_the_cross_device_list() {
10259 use crate::community::migration;
10260 let (bed, owner, _member) = TestBed::new();
10261 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10262
10263 bed.swap_to(&owner);
10264 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10265 let v1_cid = v1.id.to_hex();
10266 v1.owner_attestation = Some({
10267 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10268 .finalize(&owner.keys).unwrap().as_json()
10269 });
10270 crate::db::community::save_community(&v1).unwrap();
10271
10272 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10273
10274 let list = fetch_fragments(&bed.relay, &bed.relays).await.unwrap()
10276 .expect("the wizard published a community list").list;
10277 assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
10278 assert!(
10282 !list.tombstones.iter().any(|t| t.community_id == v1_cid),
10283 "migration must not tombstone the v1 community"
10284 );
10285 }
10286
10287 #[tokio::test]
10292 async fn wizard_refuses_while_a_drive_holds_the_claim() {
10293 use crate::community::migration;
10294 let (bed, owner, _member) = TestBed::new();
10295 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10296
10297 bed.swap_to(&owner);
10298 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10299 let v1_cid = v1.id.to_hex();
10300 v1.owner_attestation = Some({
10301 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10302 .finalize(&owner.keys).unwrap().as_json()
10303 });
10304 crate::db::community::save_community(&v1).unwrap();
10305
10306 migration::test_hold_drive_claim(&v1_cid);
10308 let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
10309 assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
10310 assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
10312 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
10313
10314 migration::test_release_drive_claim(&v1_cid);
10316 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10317 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
10318 }
10319
10320 #[tokio::test]
10326 async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
10327 use crate::community::migration;
10328 let (bed, owner, _member) = TestBed::new();
10329 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10330 let relay = std::sync::Arc::new(MemoryRelay::new());
10332
10333 bed.swap_to(&owner);
10334 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10335 let v1_cid = v1.id.to_hex();
10336 let v1_channel = v1.channels[0].id.to_hex();
10337 v1.owner_attestation = Some({
10338 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10339 .finalize(&owner.keys).unwrap().as_json()
10340 });
10341 crate::db::community::save_community(&v1).unwrap();
10342
10343 let twin = create_migration_twin(
10346 &*relay, "Guild", bed.relays.clone(), None,
10347 (v1.channels[0].id, "general".to_string()),
10348 ).await.unwrap();
10349 let v2_id = twin.identity.community_id;
10350 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
10351 crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
10352
10353 let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
10355
10356 let wizard = crate::rt::spawn({
10357 let relay = relay.clone();
10358 let v1 = v1.clone();
10359 async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
10360 });
10361
10362 crate::rt::time::sleep(std::time::Duration::from_millis(300)).await;
10364 assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
10365 assert!(
10366 crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
10367 "the fence must not be stamped while the follow lock is held"
10368 );
10369
10370 drop(held);
10372 let flipped = wizard.await.unwrap().unwrap();
10373 assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
10374 assert_eq!(
10375 crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
10376 Some(v2_hex.as_str()),
10377 "the channel row is stitched to the twin, not pruned"
10378 );
10379 }
10380
10381 #[tokio::test]
10386 async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
10387 use crate::community::migration;
10388 let (bed, owner, banned) = TestBed::new();
10389 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10390
10391 bed.swap_to(&owner);
10393 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10394 let v1_cid = v1.id.to_hex();
10395 v1.owner_attestation = Some({
10396 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10397 .finalize(&owner.keys).unwrap().as_json()
10398 });
10399 crate::db::community::save_community(&v1).unwrap();
10400 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
10401
10402 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10403
10404 bed.swap_to(&banned);
10406 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10407 m_v1.id = v1.id;
10408 m_v1.server_root_key = v1.server_root_key.clone();
10409 m_v1.channels[0].id = v1.channels[0].id;
10410 m_v1.owner_attestation = v1.owner_attestation.clone();
10411 crate::db::community::save_community(&m_v1).unwrap();
10412 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
10413
10414 let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
10416 let payload = migration::parse_migration_payload(&raw).unwrap();
10417 assert!(payload.m.is_some());
10418 let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
10419 assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
10420 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
10421 assert!(
10422 crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
10423 "banned member never acquires the v2 twin"
10424 );
10425 }
10426
10427 #[tokio::test]
10432 async fn wizard_resume_continues_on_the_same_twin() {
10433 use crate::community::migration;
10434 let (bed, owner, banned) = TestBed::new();
10435 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10436
10437 bed.swap_to(&owner);
10438 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10439 let mut sibling = v1.channels[0].clone();
10441 sibling.id = crate::community::ChannelId(crate::community::random_32());
10442 sibling.name = "offtopic".into();
10443 v1.channels.push(sibling.clone());
10444 let v1_cid = v1.id.to_hex();
10445 v1.owner_attestation = Some({
10446 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10447 .finalize(&owner.keys).unwrap().as_json()
10448 });
10449 crate::db::community::save_community(&v1).unwrap();
10450 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
10451
10452 let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
10455 let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
10456 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
10457
10458 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10460 assert_eq!(v2_hex, minted_hex, "no second twin was minted");
10461 let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
10462 assert_eq!(ledger_v2, minted_hex);
10463 assert_eq!(phase, migration::PHASE_FLIPPED);
10464 assert_eq!(
10467 crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
10468 Some(minted_hex.as_str()),
10469 "sibling channel re-parented by the resumed run"
10470 );
10471 let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
10472 let wire_banlist = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
10473 .await
10474 .unwrap()
10475 .banned;
10476 assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
10477 "the resumed banlist clone is folded from the twin's wire control plane");
10478
10479 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
10482 let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10483 assert_eq!(healed, minted_hex);
10484 let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
10485 assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
10486
10487 let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
10493 let v1b_cid = v1b.id.to_hex();
10494 v1b.owner_attestation = Some({
10495 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
10496 .finalize(&owner.keys).unwrap().as_json()
10497 });
10498 crate::db::community::save_community(&v1b).unwrap();
10499 let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
10500 let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
10501 crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
10502 crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
10504 assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
10505 assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
10506 }
10507
10508 #[tokio::test]
10513 async fn birth_refound_seeds_an_explicit_roster() {
10514 let (bed, owner, _m) = TestBed::new();
10515 bed.swap_to(&owner);
10516 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
10517 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
10518 assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
10519 let ghost_a = Keys::generate().public_key();
10521 let ghost_b = Keys::generate().public_key();
10522
10523 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
10524 assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
10525
10526 let members = memberlist(&bed.relay, &rolled).await.unwrap();
10529 assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
10530 assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
10531
10532 let _ = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
10536 .expect("the epoch-1 twin verifies from its compacted control plane");
10537
10538 let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
10541 assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
10542 assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
10543 }
10544
10545 #[tokio::test]
10549 async fn birth_refound_ignores_a_banned_seed_entry() {
10550 let (bed, owner, _m) = TestBed::new();
10551 bed.swap_to(&owner);
10552 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
10553 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
10554 let good = Keys::generate().public_key();
10555 let banned = Keys::generate();
10556 set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
10558 let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
10559
10560 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
10561 .expect("a banned seed entry is filtered, not a permanent verify-back wedge");
10562 assert_eq!(rolled.root_epoch, Epoch(1));
10563 let members = memberlist(&bed.relay, &rolled).await.unwrap();
10564 assert!(members.contains(&good), "the non-banned seed lands");
10565 assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
10566 }
10567
10568 #[tokio::test]
10574 async fn a_seeded_member_receives_a_later_refound_rekey() {
10575 let (bed, owner, _m) = TestBed::new();
10576 bed.swap_to(&owner);
10577 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
10578 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
10579 let ghost = Keys::generate();
10580 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
10582 assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
10583
10584 let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
10590 assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
10591 assert!(
10592 memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
10593 "a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
10594 );
10595 }
10596
10597 #[tokio::test]
10600 async fn v1_admin_stays_admin_across_migration() {
10601 use crate::community::migration;
10602 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10603 let (bed, owner, admin) = TestBed::new();
10604 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10605
10606 bed.swap_to(&owner);
10607 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10608 let v1_cid = v1.id.to_hex();
10609 v1.owner_attestation = Some({
10610 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10611 .finalize(&owner.keys).unwrap().as_json()
10612 });
10613 crate::db::community::save_community(&v1).unwrap();
10614 let admin_role = Role::admin("a1".repeat(32));
10616 let roles = CommunityRoles {
10617 roles: vec![admin_role.clone()],
10618 grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
10619 };
10620 crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
10621
10622 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10623 let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
10624
10625 let authority = fetch_authority(&bed.relay, &twin).await;
10627 assert!(
10628 authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
10629 "the v1 admin is an admin on the v2 twin"
10630 );
10631 assert!(
10632 !authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
10633 "a non-admin gains no authority"
10634 );
10635 }
10636
10637 #[tokio::test]
10646 async fn an_unsealed_v1_that_was_migrated_away_still_heals() {
10647 use crate::community::migration;
10648 let (bed, owner, member) = TestBed::new();
10649 bed.swap_to(&owner);
10650
10651 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10652 let v1_cid = v1.id.to_hex();
10653 v1.owner_attestation = Some({
10654 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10655 .finalize(&owner.keys).unwrap().as_json()
10656 });
10657 crate::db::community::save_community(&v1).unwrap();
10658
10659 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10661 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10662
10663 bed.swap_to(&member);
10666 crate::db::community::save_community(&v1).unwrap();
10667 assert!(
10668 !crate::db::community::get_community_dissolved(&v1_cid).unwrap(),
10669 "precondition: the wedged device has NOT sealed its v1 row"
10670 );
10671
10672 assert!(
10674 crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10675 "an unsealed, migrated-away v1 must be probed"
10676 );
10677
10678 migration::sweep_dissolved_for_migration(&bed.relay).await;
10679
10680 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "sealed by the sweep");
10682 assert_eq!(
10683 crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(),
10684 Some(v2_hex.as_str()),
10685 "flipped to the same twin the first device produced"
10686 );
10687 assert!(
10688 !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10689 "and the sweep converges — no re-probing forever"
10690 );
10691 }
10692
10693 #[tokio::test]
10698 async fn sweep_marks_checked_only_on_an_owner_tombstone() {
10699 use crate::community::migration;
10700 let (bed, owner, stranger) = TestBed::new();
10701
10702 bed.swap_to(&owner);
10703 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10704 let v1_cid = v1.id.to_hex();
10705 v1.owner_attestation = Some({
10706 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10707 .finalize(&owner.keys).unwrap().as_json()
10708 });
10709 crate::db::community::save_community(&v1).unwrap();
10710
10711 let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
10714 let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
10715 bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
10716 crate::db::community::set_community_dissolved(&v1_cid).unwrap();
10717
10718 migration::sweep_dissolved_for_migration(&bed.relay).await;
10720 assert!(
10721 crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10722 "a stranger-only probe must not converge the sweep"
10723 );
10724
10725 let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
10727 let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
10728 bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
10729 migration::sweep_dissolved_for_migration(&bed.relay).await;
10730 assert!(
10731 !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10732 "an owner plain-dissolution converges the sweep"
10733 );
10734 }
10735
10736 #[tokio::test]
10738 async fn wizard_preflight_gates_timelock_and_ownership() {
10739 use crate::community::migration;
10740 let (bed, owner, _member) = TestBed::new();
10741 bed.swap_to(&owner);
10742 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10743 v1.owner_attestation = Some({
10744 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
10745 .finalize(&owner.keys).unwrap().as_json()
10746 });
10747 crate::db::community::save_community(&v1).unwrap();
10748
10749 let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
10751 assert!(err.contains("not unlocked"), "{err}");
10752 assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
10753 }
10754
10755 #[tokio::test]
10756 async fn public_link_full_loop() {
10757 let (bed, owner, member) = TestBed::new();
10758
10759 bed.swap_to(&owner);
10760 let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
10761 let general = community.channels[0].id;
10762 send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
10763 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10765 assert!(link.url.starts_with("https://vectorapp.io/invite/"));
10766 assert!(link.url.contains('#'), "the fragment carries the token");
10767
10768 bed.swap_to(&member);
10770 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
10771 assert_eq!(joined.id().0, community.id().0);
10772 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
10773 }
10774
10775 #[test]
10776 fn bundle_of_snapshots_the_held_icon() {
10777 let owner = Keys::generate();
10778 let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
10779 let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
10780 let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
10781 c.icon = Some(icon.clone());
10782 let bundle = bundle_of(&c, BundleAudience::Link, None, None, None);
10783 assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
10784 }
10785
10786 #[test]
10787 fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
10788 let (_tmp, _guard, _owner) = init_test_db();
10793 let cur_root = [9u8; 32];
10794 let cid = crate::community::CommunityId([1u8; 32]);
10795 let cid_hex = cid.to_hex();
10796
10797 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10799 assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
10800
10801 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
10803 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
10804 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10805 assert_eq!(roots[0], cur_root, "current root leads");
10806 assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
10807 assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
10808 assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
10810
10811 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
10813 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10814 assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
10815
10816 for e in 3..20u64 {
10818 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
10819 }
10820 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10821 assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
10822 }
10823
10824 #[tokio::test]
10825 async fn public_link_preview_shows_live_name_and_icon_without_joining() {
10826 let (bed, owner, member) = TestBed::new();
10827
10828 bed.swap_to(&owner);
10829 let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
10830 let icon = control::ImageRef {
10833 url: "https://blossom.example/soap".into(),
10834 key: "k".into(),
10835 nonce: "n".into(),
10836 hash: "h".into(),
10837 extra: Default::default(),
10838 };
10839 let mut meta = community.metadata();
10840 meta.icon = Some(icon.clone());
10841 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
10842 let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
10844
10845 bed.swap_to(&member);
10847 let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
10848 assert_eq!(preview.name, "Soapbox");
10849 assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
10850 assert!(
10851 crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
10852 "previewing must not persist a membership"
10853 );
10854 }
10855
10856 #[tokio::test]
10857 async fn a_previewed_join_reuses_the_verified_fold() {
10858 let (bed, owner, member) = TestBed::new();
10859 bed.swap_to(&owner);
10860 let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
10861 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10862
10863 bed.swap_to(&member);
10864 let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
10865 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
10866 assert_eq!(joined.id().0, community.id().0);
10867 assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
10868 assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
10871 }
10872
10873 #[tokio::test]
10874 async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
10875 let (bed, owner, member) = TestBed::new();
10876 bed.swap_to(&owner);
10877 let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
10878 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10879
10880 bed.swap_to(&member);
10881 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
10882
10883 assert!(!sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty(), "the seed folds fresh events");
10885 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
10886 let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
10887 assert!(cursor > 0, "the cursor advanced past zero");
10888 let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
10889 let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
10890 assert_eq!(stored, live, "stored fold == live fold after the seed");
10891 assert!(stored.contains(&member.keys.public_key()));
10892
10893 assert!(sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty());
10895
10896 bed.swap_to(&owner);
10898 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
10899 bed.swap_to(&member);
10900 assert!(!sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty(), "the kick lands incrementally");
10901 assert!(
10902 !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
10903 "an owner kick removes the member from the stored fold"
10904 );
10905 }
10906
10907 #[tokio::test]
10908 async fn a_preview_then_revoke_still_refuses_the_join() {
10909 let (bed, owner, member) = TestBed::new();
10910 bed.swap_to(&owner);
10911 let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
10912 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10913
10914 bed.swap_to(&member);
10916 let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
10917 assert_eq!(p.name, "RevokeRace");
10918 bed.swap_to(&owner);
10919 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
10920 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
10921
10922 bed.swap_to(&member);
10925 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
10926 assert!(err.contains("revoked"), "got: {err}");
10927 }
10928
10929 #[tokio::test]
10930 async fn a_revoked_link_refuses_to_join() {
10931 let (bed, owner, member) = TestBed::new();
10932 bed.swap_to(&owner);
10933 let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
10934 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10935 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
10937 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
10938
10939 bed.swap_to(&member);
10940 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
10941 assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
10942 }
10943
10944 #[tokio::test]
10945 async fn an_expired_direct_invite_refuses_to_join() {
10946 let (bed, owner, member) = TestBed::new();
10947 bed.swap_to(&owner);
10948 let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
10949 let inviter = owner.keys.clone();
10951 let mut bundle = bundle_of(&community, BundleAudience::Link, Some(inviter.public_key()), Some(1_000), None);
10952 bundle.expires_at = Some(1_000); let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
10954 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
10955
10956 bed.swap_to(&member);
10957 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10958 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
10959 assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
10960 }
10961
10962 #[tokio::test]
10963 async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
10964 let (bed, owner, member) = TestBed::new();
10968 bed.swap_to(&owner);
10969 let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
10970 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10971 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
10972
10973 let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
10976
10977 bed.swap_to(&member);
10978 let err = accept_public_link(&union, &link.url).await.unwrap_err();
10979 assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
10980 }
10981
10982 #[test]
10983 fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
10984 let owner = Keys::generate();
10990 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
10991 let hex = crate::simd::hex::bytes_to_hex_32;
10992 let root = [0x11u8; 32];
10993 let mut bundle = CommunityInvite {
10994 community_id: hex(&identity.community_id.0),
10995 owner: hex(&identity.owner_xonly),
10996 owner_salt: hex(&identity.owner_salt),
10997 community_root: hex(&root),
10998 root_epoch: 0,
10999 control_pk: None,
11000 channels: vec![],
11001 relays: vec!["wss://r".into()],
11002 name: "X".into(),
11003 icon: None,
11004 expires_at: None,
11005 creator_npub: None,
11006 label: None,
11007 extra: Default::default(),
11008 };
11009 bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
11010 .map(|i| {
11011 let mut id = [0u8; 32];
11012 id[..8].copy_from_slice(&(i as u64).to_be_bytes());
11013 invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
11014 })
11015 .collect();
11016 assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
11017 }
11018
11019 #[tokio::test]
11020 async fn a_join_swap_between_fetch_and_save_lands_in_the_joining_account() {
11021 let (bed, owner, member) = TestBed::new();
11025 bed.swap_to(&owner);
11026 let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
11027 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
11028 bed.swap_to(&member);
11029 let joiner = crate::db::current_session();
11030 let swap_relay = SwapMidFetch::arm(&bed.relay);
11031 accept_public_link(&swap_relay, &link.url).await.unwrap();
11032
11033 assert!(
11034 crate::db::with_session(joiner, async {
11035 crate::db::community::load_community_v2(community.id()).unwrap().is_some()
11036 })
11037 .await,
11038 "the join completed for the account that accepted the link"
11039 );
11040 }
11041
11042 #[tokio::test]
11043 async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
11044 let (_tmp, _guard, owner) = init_test_db();
11047 let relay = MemoryRelay::new();
11048 let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
11049 let empty = MemoryRelay::new();
11052 let members = memberlist(&empty, &community).await.unwrap();
11053 assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
11054 }
11055
11056 #[tokio::test]
11057 async fn an_expiring_minted_invite_refuses_after_the_deadline() {
11058 let (bed, owner, member) = TestBed::new();
11061 bed.swap_to(&owner);
11062 let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
11063 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
11064 .await
11065 .unwrap();
11066
11067 bed.swap_to(&member);
11068 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
11069 assert!(
11070 accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
11071 "a minted expiring invite refuses past its deadline"
11072 );
11073 }
11074
11075 #[tokio::test]
11076 async fn a_member_who_leaves_drops_from_the_memberlist() {
11077 let (bed, owner, member) = TestBed::new();
11078 bed.swap_to(&owner);
11079 let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
11080 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
11081
11082 bed.swap_to(&member);
11083 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
11084 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
11085 crate::rt::time::sleep(std::time::Duration::from_millis(2)).await;
11087 leave_community(&bed.relay, &joined).await.unwrap();
11088
11089 bed.swap_to(&owner);
11090 let members = memberlist(&bed.relay, &community).await.unwrap();
11091 assert!(members.contains(&owner.keys.public_key()));
11092 assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
11093 }
11094
11095 #[tokio::test]
11096 async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
11097 let (bed, owner, member) = TestBed::new();
11100 bed.swap_to(&owner);
11101 let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
11102 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
11103
11104 bed.swap_to(&member);
11105 assert!(
11106 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
11107 "the owner's community must be invisible in the member's account DB"
11108 );
11109 assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
11110 }
11111
11112 async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
11120 let group = control::ControlPlane::of(&community).write_group().unwrap();
11121 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
11122 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
11123 let mut head: Option<(u64, [u8; 32])> = None;
11124 for w in &wraps {
11125 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
11126 if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
11127 head = Some((ed.version, ed.self_hash));
11128 }
11129 }
11130 }
11131 head.map(|(_, h)| h)
11132 }
11133
11134 async fn cite_on_relay(
11139 relay: &MemoryRelay,
11140 community: &CommunityV2,
11141 signer: &Keys,
11142 ) -> Option<crate::community::edition::AuthorityCitation> {
11143 if community.owner().ok() == Some(signer.public_key()) {
11144 return None;
11145 }
11146 let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
11147 let group = control::ControlPlane::of(&community).write_group().unwrap();
11148 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
11149 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
11150 let mut head: Option<(u64, [u8; 32])> = None;
11151 for w in &wraps {
11152 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
11153 if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
11154 head = Some((ed.version, ed.self_hash));
11155 }
11156 }
11157 }
11158 head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
11159 }
11160
11161 async fn publish_channel_edition(
11162 relay: &MemoryRelay,
11163 community: &CommunityV2,
11164 signer: &Keys,
11165 channel_id: &ChannelId,
11166 name: &str,
11167 private: bool,
11168 version: u64,
11169 deleted: bool,
11170 ) {
11171 let group = control::ControlPlane::of(&community).write_group().unwrap();
11172 let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
11173 let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
11174 let content = serde_json::to_string(&meta).unwrap();
11175 let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
11176 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
11177 relay.publish(&wrap, &community.relays).await.unwrap();
11178 }
11179
11180 #[allow(clippy::too_many_arguments)]
11184 async fn publish_channel_edition_at(
11185 relay: &MemoryRelay,
11186 community: &CommunityV2,
11187 signer: &Keys,
11188 channel_id: &ChannelId,
11189 name: &str,
11190 version: u64,
11191 at_secs: u64,
11192 ) {
11193 let group = control::ControlPlane::of(community).write_group().unwrap();
11194 let meta = control::ChannelMetadata { name: name.into(), private: false, deleted: None, ..Default::default() };
11195 let content = serde_json::to_string(&meta).unwrap();
11196 let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, None, &content, at_secs, None);
11197 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
11198 relay.publish(&wrap, &community.relays).await.unwrap();
11199 }
11200
11201 async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
11204 publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
11205 }
11206
11207 async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
11210 let group = control::ControlPlane::of(&community).write_group().unwrap();
11211 let prev = head_hash_on_relay(relay, community, &community.id().0).await;
11212 let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
11213 let content = serde_json::to_string(&meta).unwrap();
11214 let cite = cite_on_relay(relay, community, signer).await;
11215 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());
11216 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
11217 relay.publish(&wrap, &community.relays).await.unwrap();
11218 }
11219
11220 #[test]
11221 fn metadata_apply_captures_undriven_fields_for_republish() {
11222 let owner = Keys::generate();
11223 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
11224 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
11225 let general = held.channels[0].id;
11226
11227 let mut custom = serde_json::Map::new();
11229 custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
11230 let mut extra = serde_json::Map::new();
11231 extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
11232 let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
11233 assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
11234 assert_eq!(held.meta_custom, Some(custom.clone()));
11235 assert_eq!(held.meta_extra, extra);
11236 assert_eq!(held.metadata().custom, Some(custom));
11238 assert_eq!(held.metadata().extra, held.meta_extra);
11239
11240 let mut ch_custom = serde_json::Map::new();
11242 ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
11243 let ch_meta = control::ChannelMetadata {
11244 name: "general".into(),
11245 private: false,
11246 voice: Some(true),
11247 deleted: None,
11248 custom: Some(ch_custom.clone()),
11249 extra: Default::default(),
11250 };
11251 assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
11252 let ch = held.channel(&general).unwrap();
11253 assert_eq!(ch.voice, Some(true));
11254 assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
11255 let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
11256 assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
11257 assert_eq!(rename.custom, Some(ch_custom));
11258 }
11259
11260 #[test]
11261 fn community_metadata_apply_sets_and_clears_images() {
11262 let owner = Keys::generate();
11263 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
11264 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
11265
11266 let icon = control::ImageRef {
11267 url: "https://blossom.example/i".into(),
11268 key: "k".into(),
11269 nonce: "n".into(),
11270 hash: "h".into(),
11271 extra: Default::default(),
11272 };
11273 let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
11274 assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
11275 assert_eq!(held.icon.as_ref(), Some(&icon));
11276
11277 let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
11279 assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
11280 assert_eq!(held.icon, None);
11281 }
11282
11283 async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
11285 publish_role_at(relay, community, signer, role, version, 1_000).await
11286 }
11287
11288 async fn publish_filler_edition(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64, ts: u64) {
11292 let group = control::ControlPlane::of(&community).write_group().unwrap();
11293 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
11294 let content = crate::community::v2::roles::role_content_json(role).unwrap();
11295 let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, None, &content, ts, None);
11296 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(ts)).unwrap();
11297 relay.publish(&wrap, &community.relays).await.unwrap();
11298 }
11299
11300 async fn publish_role_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64, ts: u64) {
11305 let group = control::ControlPlane::of(&community).write_group().unwrap();
11306 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
11307 let prev = head_hash_on_relay(relay, community, &role_id).await;
11308 let content = crate::community::v2::roles::role_content_json(role).unwrap();
11309 let cite = cite_on_relay(relay, community, signer).await;
11310 let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, ts, cite.as_ref());
11311 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(ts)).unwrap();
11312 relay.publish(&wrap, &community.relays).await.unwrap();
11313 }
11314
11315 fn rotate_view(c: &CommunityV2, new_root: [u8; 32], epoch: u64) -> CommunityV2 {
11319 let mut r = c.clone();
11320 r.community_root = new_root;
11321 r.root_epoch = Epoch(epoch);
11322 r.control_root = Some(new_root);
11323 r.control_pk = Some(crate::community::v2::derive::control_signer_group_key(&new_root, r.id(), Epoch(epoch)).pk());
11324 r
11325 }
11326
11327 async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
11333 let group = control::ControlPlane::of(&community).write_group().unwrap();
11334 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
11335 let prev = head_hash_on_relay(relay, community, &eid).await;
11336 let grant = MemberGrant { member: member.to_hex(), role_ids };
11337 let control_wrap = community.control_root.filter(|_| community.control_pk.is_some()).map(|cr| {
11338 let ck = nostr_sdk::prelude::nip44::v2::ConversationKey::derive(signer.secret_key(), member).unwrap();
11339 let payload = crate::community::cipher::encrypt_with_random_nonce(&ck, super::rekey::control_wrap_b64(community.root_epoch, &cr).as_bytes()).unwrap();
11340 base64_simd::STANDARD.encode_to_string(&payload)
11341 });
11342 let content = crate::community::v2::roles::grant_content_json_with_wrap(&grant, control_wrap).unwrap();
11343 let cite = cite_on_relay(relay, community, signer).await;
11344 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
11345 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
11346 relay.publish(&wrap, &community.relays).await.unwrap();
11347 }
11348
11349 async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
11351 let group = control::ControlPlane::of(&community).write_group().unwrap();
11352 let eid = crate::community::v2::derive::banlist_locator(community.id());
11353 let prev = head_hash_on_relay(relay, community, &eid).await;
11354 let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
11355 let cite = cite_on_relay(relay, community, signer).await;
11356 let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
11357 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
11358 relay.publish(&wrap, &community.relays).await.unwrap();
11359 }
11360
11361 fn admin_role(role_id: &str, perms: u64) -> Role {
11362 Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0, extra: Default::default() }
11363 }
11364
11365 async fn create_legacy_community(relay: &MemoryRelay, owner: &Keys, name: &str, relays: Vec<String>) -> CommunityV2 {
11370 let identity = control::CommunityIdentity::mint(&owner.public_key());
11371 let root = crate::community::random_32();
11372 let group = control_group_key(&root, &identity.community_id, Epoch(0));
11373 let meta = control::CommunityMetadata { name: name.into(), relays: relays.clone(), ..Default::default() };
11374 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);
11375 let ch_id = ChannelId(crate::community::random_32());
11376 let ch = control::ChannelMetadata { name: "general".into(), private: false, ..Default::default() };
11377 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);
11378 for r in [&meta_rumor, &ch_rumor] {
11379 let (w, _) = control::seal_control_edition(r, &group, owner, Timestamp::from_secs(1_000)).unwrap();
11380 relay.publish(&w, &relays).await.unwrap();
11381 }
11382 let community = CommunityV2 {
11383 identity,
11384 community_root: root,
11385 root_epoch: Epoch(0),
11386 control_pk: None,
11387 control_root: None,
11388 name: name.into(),
11389 description: None,
11390 icon: None,
11391 banner: None,
11392 meta_custom: None,
11393 meta_extra: Default::default(),
11394 relays,
11395 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() }],
11396 dissolved: false,
11397 created_at_ms: 1_000_000,
11398 };
11399 crate::db::community::save_community_v2(&community).unwrap();
11400 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11401 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &root);
11402 let _ = follow_control(relay, &community).await;
11403 crate::db::community::load_community_v2(community.id()).unwrap().unwrap()
11404 }
11405
11406 #[tokio::test]
11407 async fn a_refounding_mints_the_split_and_delivers_the_pair_by_staffness() {
11408 let (bed, owner, admin) = TestBed::new();
11413 bed.swap_to(&owner);
11414 let community = create_community(&bed.relay, "SplitRefound", bed.relays.clone(), None).await.unwrap();
11415 assert!(community.control_pk.is_some() && community.control_root.is_some(), "genesis mints the split");
11416
11417 let rid = "d1".repeat(32);
11420 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11421 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
11422 let plain = Keys::generate();
11423 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
11424 let join = guestbook::build_join_rumor(plain.public_key(), None, 1_000);
11425 let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &plain, Timestamp::from_secs(1_000)).unwrap();
11426 bed.relay.publish(&w, &community.relays).await.unwrap();
11427
11428 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
11430 bed.swap_to(&admin);
11431 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
11432 assert_eq!(joined.control_pk, community.control_pk, "the bundle handed over the address");
11433 assert_eq!(joined.control_root, None, "a bundle never carries the secret");
11434 let _ = follow_control(&bed.relay, &joined).await;
11435 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
11436 assert_eq!(joined.control_root, community.control_root, "the Grant's control_wrap delivered the secret");
11437
11438 bed.swap_to(&owner);
11440 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
11441 assert_eq!(refounded.root_epoch, Epoch(1));
11442 assert!(refounded.control_pk.is_some() && refounded.control_root.is_some());
11443 assert_ne!(refounded.control_pk, community.control_pk, "the pair rolls with the root");
11444
11445 let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
11448 let chunks = fetch_rekey_chunks(&bed.relay, &community.relays, &base_group).await.unwrap();
11449 let rotation = &rekey::collect_rotations(&chunks)[0];
11450 async fn open_as(rotation: &rekey::Rotation, community_id: &crate::community::CommunityId, keys: &Keys) -> rekey::BaseKeyDelivery {
11451 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");
11452 let signer = crate::signer::ActiveSigner::Keys(keys.clone());
11453 rekey::open_base_blob(&signer, &rotation.rotator, community_id, Epoch(1), blob).await.unwrap()
11454 }
11455 let admin_delivery = open_as(rotation, community.id(), &admin.keys).await;
11456 assert_eq!(admin_delivery.control_root, refounded.control_root, "staff blob = 136, secret included");
11457 let plain_delivery = open_as(rotation, community.id(), &plain).await;
11458 assert_eq!(plain_delivery.control_pk.map(|p| PublicKey::from_slice(p.as_slice()).unwrap()), refounded.control_pk);
11459 assert_eq!(plain_delivery.control_root, None, "member blob = 104, never the secret");
11460
11461 let legacy_new = control_group_key(&refounded.community_root, community.id(), Epoch(1));
11464 let q = |pk: String| Query { kinds: vec![stream::KIND_WRAP], authors: vec![pk], limit: Some(50), ..Default::default() };
11465 assert!(bed.relay.fetch(&q(legacy_new.pk_hex()), &community.relays).await.unwrap().is_empty(), "no legacy mirror");
11466 assert!(!bed.relay.fetch(&q(refounded.control_pk.unwrap().to_hex()), &community.relays).await.unwrap().is_empty(), "the compaction rides the split address");
11467
11468 bed.swap_to(&admin);
11470 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
11471 let adopted = follow.updated.expect("the admin adopts the rotation");
11472 assert_eq!(adopted.root_epoch, Epoch(1));
11473 assert_eq!(adopted.control_pk, refounded.control_pk);
11474 assert_eq!(adopted.control_root, refounded.control_root, "staff crossing a rotation get the new secret in the blob");
11475 }
11476
11477 #[tokio::test]
11478 async fn a_rescue_lets_a_stranded_member_walk_every_missed_hop() {
11479 let (_tmp, _guard, _owner) = init_test_db();
11484 let relay = MemoryRelay::new();
11485 let community = create_community(&relay, "Stranded", vec!["wss://r".into()], None).await.unwrap();
11486 let stranded = Keys::generate();
11487
11488 let e1 = refound_community(&relay, &community, &[]).await.unwrap();
11491 let e2 = refound_community(&relay, &e1, &[]).await.unwrap();
11492 assert_eq!(e2.root_epoch, Epoch(2));
11493
11494 let hop1 = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
11496 let chunks = fetch_rekey_chunks(&relay, &community.relays, &hop1).await.unwrap();
11497 let r1 = &rekey::collect_rotations(&chunks)[0];
11498 assert!(
11499 rekey::find_my_blob(&r1.blobs, &r1.rotator.to_bytes(), &stranded.public_key().to_bytes(), r1.scope, r1.new_epoch).is_none(),
11500 "genuinely stranded before the rescue"
11501 );
11502
11503 let report = rescue_stranded_members(&relay, &e2, &[stranded.public_key()]).await.unwrap();
11504 assert_eq!(report.hops, 2, "one catch-up per missed hop");
11505 assert_eq!(report.hops_skipped, 0);
11506
11507 let signer = crate::signer::ActiveSigner::Keys(stranded.clone());
11510 let mut held_root = community.community_root;
11511 for e in 1..=2u64 {
11512 let group = base_rekey_group_key(&held_root, community.id(), Epoch(e));
11513 let chunks = fetch_rekey_chunks(&relay, &community.relays, &group).await.unwrap();
11514 let rotations = rekey::collect_rotations(&chunks);
11515 let rot = rotations
11516 .iter()
11517 .find(|r| {
11518 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &stranded.public_key().to_bytes(), r.scope, r.new_epoch).is_some()
11519 })
11520 .expect("a rotation now carries our blob");
11521 let blob = rekey::find_my_blob(&rot.blobs, &rot.rotator.to_bytes(), &stranded.public_key().to_bytes(), rot.scope, rot.new_epoch).unwrap();
11522 let delivery = rekey::open_base_blob(&signer, &rot.rotator, community.id(), Epoch(e), blob).await.unwrap();
11523 held_root = delivery.new_root;
11524 }
11525 assert_eq!(held_root, e2.community_root, "the walk ends on the live root");
11526
11527 let chunks = fetch_rekey_chunks(&relay, &community.relays, &hop1).await.unwrap();
11532 let rotations = rekey::collect_rotations(&chunks);
11533 assert_eq!(rotations.len(), 1, "catch-up merged into the original rotation, not a fork");
11534 assert!(rotations[0].is_complete(), "the extended set still reads complete");
11535 let owner_pk = e2.owner().unwrap();
11536 assert!(
11537 rekey::find_my_blob(&rotations[0].blobs, &rotations[0].rotator.to_bytes(), &owner_pk.to_bytes(), rotations[0].scope, rotations[0].new_epoch).is_some(),
11538 "the original recipient's blob survived the extension"
11539 );
11540
11541 let wire = wire_guestbook_members(&relay, &e2).await.unwrap();
11544 assert!(wire.contains(&stranded.public_key()), "the invite page's fold now includes them");
11545 }
11546
11547 #[tokio::test]
11548 async fn a_non_owner_refounders_snapshot_seeds_the_roster() {
11549 let (_tmp, _guard, _owner) = init_test_db();
11555 let relay = MemoryRelay::new();
11556 let community = create_community(&relay, "MintedBy", vec!["wss://r".into()], None).await.unwrap();
11557 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11558 let owner = community.owner().unwrap();
11559
11560 let admin = Keys::generate();
11563 let mut at_epoch_1 = community.clone();
11564 at_epoch_1.root_epoch = Epoch(1);
11565 record_epoch_minter(&cid_hex, 1, &admin.public_key().to_hex());
11566
11567 let cut = Keys::generate().public_key();
11570 let join = guestbook::GuestbookEvent {
11571 epoch: None, observed_at: None, rumor_id: [11u8; 32],
11572 entry: guestbook::GuestbookEntry::Join { member: cut, invited_by: None, at_ms: 1_000 },
11573 };
11574 let snapshot = |by: &Keys, id: u8| guestbook::GuestbookEvent {
11575 epoch: Some(1), observed_at: None, rumor_id: [id; 32],
11576 entry: guestbook::GuestbookEntry::Snapshot {
11577 refounder: by.public_key(),
11578 members: vec![owner], snapshot_id: [id; 32],
11580 chunk: (1, 1),
11581 at_ms: 5_000, },
11583 };
11584
11585 let stranger = Keys::generate();
11588 crate::db::community::set_guestbook(&cid_hex, &[join.clone(), snapshot(&stranger, 0x51)], 6).unwrap();
11589 assert!(
11590 stored_memberlist(&at_epoch_1).unwrap().contains(&cut),
11591 "a snapshot from an npub that minted nothing seeds no roster"
11592 );
11593
11594 crate::db::community::set_guestbook(&cid_hex, &[join, snapshot(&admin, 0x52)], 6).unwrap();
11596 let members = stored_memberlist(&at_epoch_1).unwrap();
11597 assert!(
11598 !members.contains(&cut),
11599 "the refounder's survivor list is adopted, so a cut member stops being one"
11600 );
11601 assert!(members.contains(&owner), "a survivor the snapshot names stays");
11602 }
11603
11604 #[tokio::test]
11610 async fn reading_a_private_channel_needs_its_own_role_not_community_power() {
11611 let (bed, owner, mod_without_access) = TestBed::new();
11612 bed.swap_to(&owner);
11613 let community = create_community(&bed.relay, "Rooms", bed.relays.clone(), None).await.unwrap();
11614 let public = community.channels[0].id;
11615 let private = create_private_channel(&bed.relay, &community, "back-room").await.unwrap();
11616 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11617 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11618
11619 use nostr_sdk::prelude::ToBech32;
11620 let them = mod_without_access.keys.public_key();
11621 let them_npub = them.to_bech32().unwrap();
11622
11623 let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa7; 32]);
11626 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
11627 publish_grant(&bed.relay, &community, &owner.keys, &them, vec![admin_rid], 1).await;
11628 follow_control(&bed.relay, &community).await.unwrap();
11629 assert!(
11630 crate::VectorCore.member_has_permission(&cid_hex, &them_npub, Permissions::BAN).unwrap(),
11631 "they really do hold BAN"
11632 );
11633
11634 let pub_hex = crate::simd::hex::bytes_to_hex_32(&public.0);
11635 let priv_hex = crate::simd::hex::bytes_to_hex_32(&private.0);
11636 assert!(
11637 crate::VectorCore.member_can_read_channel(&cid_hex, &pub_hex, &them_npub).unwrap(),
11638 "a public channel is the community's"
11639 );
11640 assert!(
11641 !crate::VectorCore.member_can_read_channel(&cid_hex, &priv_hex, &them_npub).unwrap(),
11642 "BAN is not a key: community power must never open a private room"
11643 );
11644
11645 grant_channel_access(&bed.relay, &community, &private, &them).await.unwrap();
11647 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11648 let _ = follow_control(&bed.relay, &community).await;
11649 assert!(
11650 crate::VectorCore.member_can_read_channel(&cid_hex, &priv_hex, &them_npub).unwrap(),
11651 "the channel-scoped grant is what admits them"
11652 );
11653 }
11654
11655 #[tokio::test]
11667 async fn an_empty_retain_cuts_nobody_rather_than_keeping_nobody() {
11668 let (bed, owner, member) = TestBed::new();
11669 bed.swap_to(&owner);
11670 let community = create_community(&bed.relay, "EmptyRetain", bed.relays.clone(), None).await.unwrap();
11671
11672 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
11675 bed.swap_to(&member);
11676 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
11677 bed.swap_to(&owner);
11678 assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()));
11679
11680 refound_community(&bed.relay, &community, &[]).await.expect("cutting nobody must succeed");
11684 assert!(
11685 vended_to(&bed, &community, &member.keys).await,
11686 "a plain rotation must vend to a member who is neither the rotator nor the owner"
11687 );
11688 }
11689
11690 #[test]
11694 fn an_empty_retain_does_not_become_an_allow_list() {
11695 let src = include_str!("../../lib.rs");
11696 let at = src.find("pub async fn purge_and_refound").expect("purge_and_refound");
11697 let body = &src[at..at + 3000];
11698 let guard = body.find("if keep.is_empty()").expect("an empty retain must branch away from the allow-list");
11699 let retaining = body.find("refound_community_retaining").expect("the allow-list call");
11700 assert!(guard < retaining, "the branch has to come BEFORE the allow-list call");
11701 }
11702
11703 #[tokio::test]
11711 async fn a_rotation_that_would_strand_staff_refuses_to_publish() {
11712 let (bed, owner, staffer) = TestBed::new();
11713 bed.swap_to(&owner);
11714 let community = create_community(&bed.relay, "NoSilentSever", bed.relays.clone(), None).await.unwrap();
11715 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11716 let them = staffer.keys.public_key();
11717
11718 let rid = crate::simd::hex::bytes_to_hex_32(&[0xb6; 32]);
11719 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11720 publish_grant(&bed.relay, &community, &owner.keys, &them, vec![rid], 1).await;
11721 follow_control(&bed.relay, &community).await.unwrap();
11722
11723 let err = refound_community_retaining(&bed.relay, &community, &[], &[owner.keys.public_key()])
11726 .await
11727 .expect_err("must refuse rather than strand staff");
11728 assert!(
11729 err.contains("stranded"),
11730 "the refusal has to say what it protected, got: {err}"
11731 );
11732
11733 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11735 assert_eq!(held.root_epoch, community.root_epoch, "no state published");
11736 assert!(!vended_to(&bed, &community, &staffer.keys).await, "no half-rotation left on the plane");
11737 let _ = cid_hex;
11738 }
11739
11740 #[tokio::test]
11750 async fn a_granted_member_who_talks_is_not_dropped_by_an_old_snapshot() {
11751 let (bed, owner, staffer) = TestBed::new();
11752 bed.swap_to(&owner);
11753 let community = create_community(&bed.relay, "TalkingAdmin", bed.relays.clone(), None).await.unwrap();
11754 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11755 let them = staffer.keys.public_key();
11756
11757 let rid = crate::simd::hex::bytes_to_hex_32(&[0xb5; 32]);
11758 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11759 publish_grant(&bed.relay, &community, &owner.keys, &them, vec![rid], 1).await;
11760 follow_control(&bed.relay, &community).await.unwrap();
11761
11762 let spoke_at = 5_000u64;
11765 let join = guestbook::GuestbookEvent {
11766 epoch: Some(community.root_epoch.0), observed_at: None, rumor_id: [0x31; 32],
11767 entry: guestbook::GuestbookEntry::Join { member: them, invited_by: None, at_ms: spoke_at },
11768 };
11769 let snapshot = guestbook::GuestbookEvent {
11771 epoch: Some(community.root_epoch.0), observed_at: None, rumor_id: [0x32; 32],
11772 entry: guestbook::GuestbookEntry::Snapshot {
11773 refounder: owner.keys.public_key(),
11774 members: vec![owner.keys.public_key()], snapshot_id: [0x32; 32],
11776 chunk: (1, 1),
11777 at_ms: spoke_at + 1_000,
11778 },
11779 };
11780 crate::db::community::set_guestbook(&cid_hex, &[join, snapshot], 9).unwrap();
11781
11782 assert!(
11783 stored_memberlist(&community).unwrap().contains(&them),
11784 "a grant is proof of membership — an old snapshot must not shed the staff it forgot"
11785 );
11786
11787 let _ = refound_community(&bed.relay, &community, &[]).await.unwrap();
11788 assert!(
11789 vended_to(&bed, &community, &staffer.keys).await,
11790 "and the rotation must vend to them, or they are stranded holding a grant"
11791 );
11792 }
11793
11794 #[tokio::test]
11806 async fn a_rotation_vends_to_a_granted_member_the_folds_forgot() {
11807 let (bed, owner, staffer) = TestBed::new();
11808 bed.swap_to(&owner);
11809 let community = create_community(&bed.relay, "LurkingAdmin", bed.relays.clone(), None).await.unwrap();
11810 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11811 let them = staffer.keys.public_key();
11812
11813 let rid = crate::simd::hex::bytes_to_hex_32(&[0xb4; 32]);
11816 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11817 publish_grant(&bed.relay, &community, &owner.keys, &them, vec![rid], 1).await;
11818 follow_control(&bed.relay, &community).await.unwrap();
11819 assert!(
11820 crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&them.to_hex()),
11821 "they hold the grant"
11822 );
11823
11824 crate::db::community::set_guestbook(&cid_hex, &[], 1).unwrap();
11826
11827 let _rotated = refound_community(&bed.relay, &community, &[]).await.unwrap();
11828
11829 assert!(
11833 vended_to(&bed, &community, &staffer.keys).await,
11834 "a rotation removing nobody must vend to a granted member, or it severs its own staff"
11835 );
11836 }
11837
11838 #[tokio::test]
11843 async fn a_rotation_that_removes_nobody_vends_to_everybody() {
11844 let (bed, owner, member) = TestBed::new();
11845 bed.swap_to(&owner);
11846 let community = create_community(&bed.relay, "KeepAll", bed.relays.clone(), None).await.unwrap();
11847
11848 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
11850 bed.swap_to(&member);
11851 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
11852 bed.swap_to(&owner);
11853 let before = memberlist(&bed.relay, &community).await.unwrap();
11854 assert!(before.contains(&member.keys.public_key()), "present before the rotation");
11855
11856 let rotated = refound_community(&bed.relay, &community, &[]).await.unwrap();
11859 assert_eq!(rotated.root_epoch.0, community.root_epoch.0 + 1, "the epoch rolled");
11860
11861 let after = memberlist(&bed.relay, &community).await.unwrap();
11862 assert!(
11863 after.contains(&member.keys.public_key()),
11864 "a rotation removing nobody must keep everybody: {before:?} -> {after:?}"
11865 );
11866
11867 bed.swap_to(&member);
11870 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11871 let follow = follow_rekeys(&bed.relay, &held, &crate::db::current_session()).await.unwrap();
11872 assert!(!follow.self_removed, "they were not excluded");
11873 let now = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11874 assert_eq!(now.root_epoch.0, rotated.root_epoch.0, "they adopted the new epoch — a blob was vended to them");
11875 }
11876
11877 #[tokio::test]
11884 async fn revoking_admin_publishes_where_everyone_else_reads_it() {
11885 let (bed, owner, member) = TestBed::new();
11886 bed.swap_to(&owner);
11887 let community = create_community(&bed.relay, "Demotion", bed.relays.clone(), None).await.unwrap();
11888 let them = member.keys.public_key();
11889 let them_hex = them.to_hex();
11890
11891 grant_admin(&bed.relay, &community, &them).await.unwrap();
11892 let view = fetch_authority(&bed.relay, &community).await;
11893 assert!(view.roles.is_admin(&them_hex), "granted, and readable from the plane");
11894
11895 revoke_admin(&bed.relay, &community, &them).await.unwrap();
11896
11897 let view = fetch_authority(&bed.relay, &community).await;
11899 assert!(
11900 !view.roles.is_admin(&them_hex),
11901 "the demotion must be on the plane, or only the owner believes it"
11902 );
11903
11904 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11906 use nostr_sdk::prelude::ToBech32;
11907 assert!(
11908 !crate::VectorCore
11909 .member_has_permission(&cid_hex, &them.to_bech32().unwrap(), crate::community::roles::Permissions::BAN)
11910 .unwrap(),
11911 "a demoted admin holds no powers"
11912 );
11913 }
11914
11915 #[tokio::test]
11921 async fn permission_is_refused_to_everyone_the_roster_does_not_name() {
11922 let (_tmp, _guard, _owner) = init_test_db();
11923 let relay = MemoryRelay::new();
11924 let community = create_community(&relay, "Authority", vec!["wss://r".into()], None).await.unwrap();
11925 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11926 use nostr_sdk::prelude::ToBech32;
11927 let owner_npub = community.owner().unwrap().to_bech32().unwrap();
11928 let stranger = Keys::generate().public_key().to_bech32().unwrap();
11929
11930 use crate::community::roles::Permissions;
11931 for p in [Permissions::KICK, Permissions::BAN, Permissions::MANAGE_ROLES] {
11932 assert!(
11933 !crate::VectorCore.member_has_permission(&cid_hex, &stranger, p).unwrap(),
11934 "an npub the roster never granted anything holds nothing"
11935 );
11936 assert!(
11937 crate::VectorCore.member_has_permission(&cid_hex, &owner_npub, p).unwrap(),
11938 "the owner is supreme without a grant to cite"
11939 );
11940 }
11941
11942 crate::db::community::set_community_banlist(&cid_hex, &[community.owner().unwrap().to_hex()], 1).unwrap();
11944 let banned_owner_is_still_owner =
11945 crate::VectorCore.member_has_permission(&cid_hex, &owner_npub, Permissions::BAN).unwrap();
11946 assert!(banned_owner_is_still_owner, "the owner cannot be locked out of their own community");
11947
11948 assert!(crate::VectorCore.member_has_permission(&cid_hex, "not-an-npub", Permissions::BAN).is_err());
11950 }
11951
11952 #[tokio::test]
11953 async fn a_refound_keeps_a_quiet_member_the_network_fold_forgot() {
11954 let (_tmp, _guard, _owner) = init_test_db();
11959 let relay = MemoryRelay::new();
11960 let community = create_community(&relay, "QuietOnes", vec!["wss://r".into()], None).await.unwrap();
11961 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11962
11963 let quiet = Keys::generate().public_key();
11966 let ev = guestbook::GuestbookEvent { epoch: None, observed_at: None,
11967 rumor_id: [7u8; 32],
11968 entry: guestbook::GuestbookEntry::Join { member: quiet, invited_by: None, at_ms: 1_000 },
11969 };
11970 crate::db::community::set_guestbook(&cid_hex, std::slice::from_ref(&ev), 1).unwrap();
11971 assert!(stored_memberlist(&community).unwrap().contains(&quiet), "locally known");
11972 assert!(!memberlist(&relay, &community).await.unwrap().contains(&quiet), "network fold has forgotten them");
11973
11974 let rotated = refound_community(&relay, &community, &[]).await.unwrap();
11975
11976 let wire = wire_guestbook_members(&relay, &rotated).await.unwrap();
11979 assert!(wire.contains(&quiet), "the quiet member survived the rotation onto the wire");
11980
11981 let rotated2 = refound_community(&relay, &rotated, &[quiet]).await.unwrap();
11983 let wire2 = wire_guestbook_members(&relay, &rotated2).await.unwrap();
11984 assert!(!wire2.contains(&quiet), "an explicit removal is honoured");
11985 }
11986
11987 #[tokio::test]
11988 async fn revoking_a_husk_prunes_the_local_row_instead_of_erroring_forever() {
11989 let (_tmp, _guard, _owner) = init_test_db();
11995 let relay = MemoryRelay::new();
11996 let community = create_community(&relay, "Husks", vec!["wss://r".into()], None).await.unwrap();
11997 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11998
11999 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
12000 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
12001 assert_eq!(crate::db::community::list_public_invites(&cid_hex).unwrap().len(), 1);
12002
12003 let signer = crate::signer::active_signer().unwrap();
12005 let my_pk = me_pk().unwrap();
12006 let mut list = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
12007 list.entries.retain(|e| e.token != token_hex);
12008 let ev = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.unwrap();
12009 relay.publish(&ev, &community.relays).await.unwrap();
12010
12011 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
12013 assert!(
12014 crate::db::community::list_public_invites(&cid_hex).unwrap().is_empty(),
12015 "the husk row is gone — the panel can no longer show a link nobody can use or remove"
12016 );
12017
12018 }
12022
12023 #[tokio::test]
12024 async fn revoking_the_last_link_privatizes_and_rotates_but_an_earlier_revoke_does_not() {
12025 let (_tmp, _guard, _owner) = init_test_db();
12031 let relay = MemoryRelay::new();
12032 let community = create_community(&relay, "Privatize", vec!["wss://r".into()], None).await.unwrap();
12033 assert_eq!(community.root_epoch, Epoch(0));
12034
12035 let a = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
12036 let b = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
12037 assert!(community_is_public(&relay, &community).await, "two live links = Public");
12038
12039 revoke_public_link(&relay, &community, &crate::simd::hex::bytes_to_hex_16(&a.token)).await.unwrap();
12041 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12042 assert_eq!(held.root_epoch, Epoch(0), "a link remains live — not the Public→Private conversion");
12043 assert!(community_is_public(&relay, &held).await, "still Public");
12044
12045 revoke_public_link(&relay, &held, &crate::simd::hex::bytes_to_hex_16(&b.token)).await.unwrap();
12047 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12048 assert!(!community_is_public(&relay, &held).await, "no live links = Private");
12049 assert_eq!(held.root_epoch, Epoch(1), "privatizing rotated the base key");
12050 assert!(held.control_pk.is_some(), "the privatize rotation mints the split like any base rotation");
12053 assert_ne!(held.community_root, community.community_root, "the base key actually rolled");
12054 }
12055
12056 #[tokio::test]
12057 async fn a_legacy_community_upgrades_as_a_side_effect_of_its_next_refounding() {
12058 let (_tmp, _guard, owner) = init_test_db();
12061 let relay = MemoryRelay::new();
12062 let community = create_legacy_community(&relay, &owner, "Legacy", vec!["wss://r".into()]).await;
12063 assert!(community.control_pk.is_none(), "starts pre-split");
12064
12065 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
12066 assert_eq!(refounded.root_epoch, Epoch(1));
12067 assert!(refounded.control_pk.is_some() && refounded.control_root.is_some(), "the rotation minted the split");
12068 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12070 assert_eq!(reloaded.control_pk, refounded.control_pk);
12071 assert_eq!(reloaded.control_root, refounded.control_root);
12072 }
12073
12074 #[tokio::test]
12075 async fn an_old_build_deletion_recovers_from_the_stale_bundle_across_the_split() {
12076 let (bed, owner, member) = TestBed::new();
12082 bed.swap_to(&owner);
12083 let community = create_legacy_community(&bed.relay, &owner.keys, "Recover", bed.relays.clone()).await;
12084 let stale_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
12085
12086 bed.swap_to(&member);
12088 let joined = accept_parked_invite(&bed.relay, &stale_bundle, None).await.unwrap();
12089 assert!(joined.control_pk.is_none());
12090
12091 bed.swap_to(&owner);
12093 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
12094
12095 bed.swap_to(&member);
12097 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12098 crate::db::community::delete_community(&cid_hex).unwrap();
12099 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
12100
12101 let readopted = accept_parked_invite(&bed.relay, &stale_bundle, None).await.unwrap();
12104 assert_eq!(readopted.root_epoch, Epoch(0), "re-anchored at the stale epoch");
12105 let follow = follow_rekeys(&bed.relay, &readopted, &crate::db::current_session()).await.unwrap();
12107 assert!(!follow.self_removed, "never a false removal on this build");
12108 let healed = follow.updated.expect("the walk adopts the split rotation");
12109 assert_eq!(healed.root_epoch, Epoch(1));
12110 assert_eq!(healed.control_pk, refounded.control_pk, "the member reads the upgraded plane");
12111 assert_eq!(healed.control_root, None, "a plain member never receives the secret");
12112 let folded = follow_control(&bed.relay, &healed).await;
12114 assert!(folded.is_ok(), "the recovered member folds the split plane: {folded:?}");
12115 }
12116
12117 #[tokio::test]
12118 async fn an_unopenable_blob_at_my_locator_never_concludes_removal() {
12119 let (bed, owner, member) = TestBed::new();
12124 bed.swap_to(&owner);
12125 let community = create_community(&bed.relay, "Garbled", bed.relays.clone(), None).await.unwrap();
12126 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
12127 bed.swap_to(&member);
12128 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
12129
12130 bed.swap_to(&owner);
12133 let new_epoch = Epoch(1);
12134 let new_root = [0x4Du8; 32];
12135 let control_root = [0x4Eu8; 32];
12136 let control_pk = super::super::derive::control_signer_group_key(&control_root, community.id(), new_epoch).pk();
12137 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
12138 let garbled = rekey::RekeyBlob {
12139 locator: rekey::blob_locator(&owner.keys.public_key().to_bytes(), &member.keys.public_key().to_bytes(), RekeyScope::Root, new_epoch),
12140 wrapped: "bm90LWEtcmVhbC1uaXA0NC1wYXlsb2Fk".into(),
12141 };
12142 let own = rekey::build_base_blob(
12143 &crate::signer::ActiveSigner::Keys(owner.keys.clone()),
12144 &owner.keys.public_key().to_bytes(),
12145 &owner.keys.public_key(),
12146 new_epoch,
12147 &new_root,
12148 &control_pk.to_bytes(),
12149 Some(&control_root),
12150 )
12151 .await
12152 .unwrap();
12153 let base_group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
12154 let chunks = rekey::build_rekey_chunks_local(&owner.keys, &base_group, RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[garbled, own], 2_000, None, false).unwrap();
12155 for c in &chunks {
12156 bed.relay.publish(c, &community.relays).await.unwrap();
12157 }
12158
12159 bed.swap_to(&member);
12161 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
12162 assert!(!follow.self_removed, "an unopenable blob at my locator is not an exclusion");
12163 assert!(follow.updated.is_none(), "nothing adopted from an unreadable blob");
12164 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "the community survives");
12165 assert!(follow.wedged, "an unopenable key delivery must be surfaced, not swallowed");
12169 }
12170
12171 #[tokio::test]
12172 async fn a_control_wrap_is_refused_on_a_stale_epoch_or_a_non_deriving_secret() {
12173 let (bed, owner, member) = TestBed::new();
12182 bed.swap_to(&owner);
12183 let community = create_community(&bed.relay, "AdoptGate", bed.relays.clone(), None).await.unwrap();
12184 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
12185 bed.swap_to(&member);
12186 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
12187 assert!(joined.control_root.is_none());
12188
12189 let wrap_for = |epoch: Epoch, secret: &[u8; 32]| {
12191 let ck = nostr_sdk::prelude::nip44::v2::ConversationKey::derive(owner.keys.secret_key(), &member.keys.public_key()).unwrap();
12192 let ct = crate::community::cipher::encrypt_with_random_nonce(&ck, super::rekey::control_wrap_b64(epoch, secret).as_bytes()).unwrap();
12193 base64_simd::STANDARD.encode_to_string(&ct)
12194 };
12195 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.keys.public_key().to_bytes());
12196 let ed_with = |wrap: String| {
12197 let grant = MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![] };
12198 let content = crate::community::v2::roles::grant_content_json_with_wrap(&grant, Some(wrap)).unwrap();
12199 let rumor = control::build_edition_rumor(owner.keys.public_key(), vsk::GRANT, &eid, 1, None, &content, 1_000, None);
12200 control::parse_edition_rumor(&rumor).unwrap()
12201 };
12202
12203 let real = community.control_root.unwrap();
12205 let stale = ed_with(wrap_for(Epoch(joined.root_epoch.0 + 1), &real));
12206 assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&stale)).await, None, "a stale-epoch wrap is refused");
12207
12208 let forged = ed_with(wrap_for(joined.root_epoch, &[0x66u8; 32]));
12210 assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&forged)).await, None, "a non-deriving secret is refused");
12211
12212 let good = ed_with(wrap_for(joined.root_epoch, &real));
12215 assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&good)).await, Some(real), "the true wrap adopts");
12216 }
12217
12218 #[test]
12219 fn the_vault_carries_the_split_and_the_twin_strips_the_secret() {
12220 let owner = Keys::generate();
12224 let g = control::genesis(&owner, control::CommunityMetadata { name: "Vault".into(), ..Default::default() }, 1_000).unwrap();
12225 let c = CommunityV2::from_genesis(&g, "Vault", None, vec!["wss://r".into()], 0);
12226 let jm = join_material(&c);
12227 assert_eq!(jm.control_pk, c.control_pk.map(|p| p.to_hex()));
12228 assert_eq!(jm.control_root, Some(crate::simd::hex::bytes_to_hex_32(&g.control_root)));
12229 let twin = twin_join_material(&c);
12230 assert_eq!(twin.control_pk, jm.control_pk, "members get the address");
12231 assert_eq!(twin.control_root, None, "members never get the secret");
12232 let bundle = material_to_invite(&jm);
12233 assert_eq!(bundle.control_pk, jm.control_pk, "a rehydrating bundle carries the address");
12234 }
12235
12236 #[tokio::test]
12242 async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
12243 let (bed, owner, attacker) = TestBed::new();
12244 bed.swap_to(&owner);
12245 let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
12246 let victim = Keys::generate().public_key();
12247 grant_admin(&bed.relay, &community, &victim).await.unwrap();
12248
12249 let admin_rid = fetch_authority(&bed.relay, &community)
12251 .await
12252 .roles
12253 .roles
12254 .iter()
12255 .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
12256 .unwrap()
12257 .role_id
12258 .clone();
12259 publish_role(
12261 &bed.relay,
12262 &community,
12263 &attacker.keys,
12264 &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0, extra: Default::default() },
12265 2,
12266 )
12267 .await;
12268
12269 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
12270 assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
12271 assert!(
12272 authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
12273 "the floor advances only to the AUTHORIZED head (owner v1)"
12274 );
12275 assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
12276 }
12277
12278 #[tokio::test]
12279 async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
12280 let (bed, owner, attacker) = TestBed::new();
12281 bed.swap_to(&owner);
12282 let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
12283 let victim = Keys::generate();
12284 grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
12285
12286 publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
12288
12289 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
12290 assert!(
12291 authority.roles.is_admin(&victim.public_key().to_hex()),
12292 "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
12293 );
12294 }
12295
12296 #[tokio::test]
12297 async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
12298 let (bed, owner, attacker) = TestBed::new();
12299 bed.swap_to(&owner);
12300 let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
12301 let victim = Keys::generate().public_key();
12302 grant_admin(&bed.relay, &community, &victim).await.unwrap();
12303
12304 for i in 0u8..6 {
12307 let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
12308 publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
12309 }
12310
12311 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
12312 assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
12313 assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
12314 }
12315
12316 fn authority_fingerprint(a: &AuthoritySet) -> String {
12319 let mut roles = a.roles.roles.clone();
12320 roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
12321 let mut grants = a.roles.grants.clone();
12322 for g in &mut grants {
12323 g.role_ids.sort();
12324 }
12325 grants.sort_by(|x, y| x.member.cmp(&y.member));
12326 let banned: Vec<&String> = a.banned.iter().collect();
12327 serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
12328 }
12329
12330 fn folded_role<'a>(view: &'a AuthorityView, id: &str) -> &'a crate::community::roles::Role {
12331 view.roles.role(id).unwrap_or_else(|| panic!("role {id} folded"))
12332 }
12333
12334 #[tokio::test]
12335 async fn roles_are_created_at_the_bottom_edited_in_place_and_reordered() {
12336 use crate::community::roles::{Permissions, RoleScope};
12337 let (bed, owner, _m) = TestBed::new();
12338 bed.swap_to(&owner);
12339 let community = create_community(&bed.relay, "Roles", bed.relays.clone(), None).await.unwrap();
12340
12341 let a = create_role(&bed.relay, &community, "Alpha", 0, Permissions::KICK, RoleScope::Server).await.unwrap();
12342 let b = create_role(&bed.relay, &community, "Beta", 0, 0, RoleScope::Server).await.unwrap();
12343 let c = create_role(&bed.relay, &community, "Gamma", 0, 0, RoleScope::Server).await.unwrap();
12344 let view = fetch_authority(&bed.relay, &community).await;
12345 let pos = |v: &AuthorityView, id: &str| folded_role(v, id).position;
12346 assert!(pos(&view, &a) < pos(&view, &b) && pos(&view, &b) < pos(&view, &c), "each new role lands beneath the last");
12347 assert!(pos(&view, &a) >= 1, "no role claims the owner's position");
12348
12349 edit_role(&bed.relay, &community, &b, "Beta Prime", 0x59fcb3, Permissions::BAN, RoleScope::Server).await.unwrap();
12350 let view = fetch_authority(&bed.relay, &community).await;
12351 let edited = folded_role(&view, &b);
12352 assert_eq!((edited.name.as_str(), edited.color, edited.permissions.0), ("Beta Prime", 0x59fcb3, Permissions::BAN));
12353 assert_eq!(edited.position, pos(&view, &b), "an edit keeps the role where it was");
12354
12355 let moved = reorder_roles(&bed.relay, &community, &[c.clone(), a.clone(), b.clone()]).await.unwrap();
12356 assert_eq!(moved, 3, "every role whose position changed is republished");
12357 let view = fetch_authority(&bed.relay, &community).await;
12358 assert!(pos(&view, &c) < pos(&view, &a) && pos(&view, &a) < pos(&view, &b), "the fold holds the requested order");
12359 assert_eq!(pos(&view, &c), 1, "renumbered from just beneath the owner");
12360
12361 let again = reorder_roles(&bed.relay, &community, &[c, a, b]).await.unwrap();
12362 assert_eq!(again, 0, "the same order again publishes nothing");
12363 }
12364
12365 #[tokio::test]
12366 async fn new_roles_stack_above_the_access_band_and_never_share_a_position() {
12367 use crate::community::roles::{Permissions, RoleScope};
12368 let (bed, owner, _m) = TestBed::new();
12369 bed.swap_to(&owner);
12370 let community = create_community(&bed.relay, "Band", bed.relays.clone(), None).await.unwrap();
12371 create_private_channel(&bed.relay, &community, "secret").await.unwrap();
12372 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12373 let a = create_role(&bed.relay, &held, "A", 0, Permissions::empty().0, RoleScope::Server).await.unwrap();
12374 let b = create_role(&bed.relay, &held, "B", 0, Permissions::empty().0, RoleScope::Server).await.unwrap();
12375 let view = fetch_authority(&bed.relay, &held).await;
12376 let (pa, pb) = (folded_role(&view, &a).position, folded_role(&view, &b).position);
12377 assert!(pa < pb, "each new role lands beneath the last");
12378 assert!(pb < ACCESS_ROLE_POSITION, "and above the access band, never in the slot beneath it");
12379 }
12380
12381 #[tokio::test]
12382 async fn a_members_role_set_is_written_whole_and_the_owner_is_never_a_target() {
12383 use crate::community::roles::{Permissions, RoleScope};
12384 let (bed, owner, _m) = TestBed::new();
12385 bed.swap_to(&owner);
12386 let community = create_community(&bed.relay, "Grants", bed.relays.clone(), None).await.unwrap();
12387 let a = create_role(&bed.relay, &community, "A", 0, Permissions::KICK, RoleScope::Server).await.unwrap();
12388 let b = create_role(&bed.relay, &community, "B", 0, 0, RoleScope::Server).await.unwrap();
12389 let member = Keys::generate().public_key();
12390
12391 set_member_roles(&bed.relay, &community, &member, vec![a.clone(), b.clone()]).await.unwrap();
12392 let view = fetch_authority(&bed.relay, &community).await;
12393 let held: Vec<String> = view.roles.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect();
12394 assert!(held.contains(&a) && held.contains(&b), "both roles granted");
12395
12396 set_member_roles(&bed.relay, &community, &member, vec![b.clone()]).await.unwrap();
12397 let view = fetch_authority(&bed.relay, &community).await;
12398 let held: Vec<String> = view.roles.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect();
12399 assert_eq!(held, vec![b.clone()], "the set replaces, it does not append");
12400
12401 let err = set_member_roles(&bed.relay, &community, &owner.keys.public_key(), vec![a]).await.unwrap_err();
12402 assert!(err.contains("owner"), "the owner is never a role target: {err}");
12403 }
12404
12405 #[tokio::test]
12406 async fn losing_the_last_role_that_opens_a_channel_rotates_it_and_keeping_one_does_not() {
12407 use crate::community::roles::{Permissions, RoleScope};
12408 let (bed, owner, _m) = TestBed::new();
12409 bed.swap_to(&owner);
12410 let community = create_community(&bed.relay, "Keys", bed.relays.clone(), None).await.unwrap();
12411 let chan = create_private_channel(&bed.relay, &community, "secret").await.unwrap();
12412 let chan_hex = crate::simd::hex::bytes_to_hex_32(&chan.0);
12413 let reload = || crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12414 let epoch = |c: &CommunityV2| c.channel(&chan).unwrap().epoch.0;
12415
12416 let held = reload();
12417 let access = fetch_authority(&bed.relay, &held).await.roles.channel_role_ids(&chan_hex);
12418 assert!(!access.is_empty(), "the private channel came with its access role");
12419 let second = create_role(&bed.relay, &held, "Also Secret", 0, Permissions::empty().0, RoleScope::Channel(chan_hex.clone())).await.unwrap();
12420 let member = Keys::generate().public_key();
12421
12422 set_member_roles(&bed.relay, &reload(), &member, vec![access[0].clone(), second.clone()]).await.unwrap();
12423 let start = epoch(&reload());
12424
12425 set_member_roles(&bed.relay, &reload(), &member, vec![second.clone()]).await.unwrap();
12427 assert_eq!(epoch(&reload()), start, "a kept role still opens the channel");
12428
12429 set_member_roles(&bed.relay, &reload(), &member, vec![]).await.unwrap();
12431 assert_eq!(epoch(&reload()), start + 1, "losing the last access role severs them");
12432 }
12433
12434 #[test]
12435 fn a_role_author_adds_only_bits_they_hold_and_acts_only_beneath_themselves() {
12436 use crate::community::roles::Permissions;
12437 let moderator = RoleAuthor {
12438 me_hex: "m".into(),
12439 owner_hex: "o".into(),
12440 is_owner: false,
12441 rank: 3,
12442 grantable: Permissions::MANAGE_ROLES | Permissions::KICK,
12443 };
12444 assert!(moderator.may_add(0, Permissions::KICK), "a bit they hold");
12445 assert!(!moderator.may_add(0, Permissions::BAN), "a bit they lack");
12446 assert!(moderator.may_add(Permissions::BAN, Permissions::BAN | Permissions::KICK), "an existing bit may stay");
12447 assert!(moderator.may_add(Permissions::BAN, 0), "and may always be taken away");
12448 assert!(moderator.outranks(4) && !moderator.outranks(3) && !moderator.outranks(1), "strictly beneath only");
12449 let owner = RoleAuthor { is_owner: true, rank: 0, grantable: u64::MAX, ..moderator };
12450 assert!(owner.may_add(0, u64::MAX) && owner.outranks(1));
12451 }
12452
12453 #[tokio::test]
12454 async fn the_v2_authority_fold_is_order_independent() {
12455 let (bed, owner, _a) = TestBed::new();
12461 bed.swap_to(&owner);
12462 let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
12463
12464 let admin1 = Keys::generate().public_key();
12467 let admin2 = Keys::generate().public_key();
12468 grant_admin(&bed.relay, &community, &admin1).await.unwrap();
12469 grant_admin(&bed.relay, &community, &admin2).await.unwrap();
12470 let mod_rid = "5c".repeat(32);
12471 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
12472 let member = Keys::generate().public_key();
12473 publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
12474 let banned_member = Keys::generate().public_key();
12475 publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
12476 set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
12477 let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
12478 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
12479 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
12480
12481 let editions = fetch_control(&bed.relay, &community).await;
12482 let floors = load_floors(&community);
12483 assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
12484
12485 let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
12486
12487 let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
12490 let mut rev = editions.clone();
12491 rev.reverse();
12492 orders.push(rev);
12493 for shift in [1usize, 3, 5, 7] {
12494 let n = editions.len();
12495 orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
12496 }
12497 let mut zip = Vec::with_capacity(editions.len());
12499 let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
12500 while lo <= hi {
12501 zip.push(editions[lo as usize].clone());
12502 if lo != hi {
12503 zip.push(editions[hi as usize].clone());
12504 }
12505 lo += 1;
12506 hi -= 1;
12507 }
12508 orders.push(zip);
12509
12510 for (i, order) in orders.iter().enumerate() {
12511 let got = authority_fingerprint(&fold_authority(&community, order, &floors));
12512 assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
12513 }
12514 assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
12517 assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
12518 }
12519
12520 struct FetchErrors(MemoryRelay);
12522 #[async_trait::async_trait]
12523 impl crate::community::transport::Transport for FetchErrors {
12524 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
12525 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
12526 self.0.publish(e, r).await
12527 }
12528 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
12529 Err("relay down".to_string())
12530 }
12531 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
12532 self.0.publish_durable(e, r).await
12533 }
12534 }
12535
12536 #[tokio::test]
12537 async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
12538 let (bed, owner, victim) = TestBed::new();
12539 bed.swap_to(&owner);
12540 let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
12541 let victim_hex = victim.keys.public_key().to_hex();
12542 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12544 crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
12545
12546 let down = FetchErrors(MemoryRelay::new());
12549 let view = fetch_authority(&down, &community).await;
12550 assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
12551 }
12552
12553 #[tokio::test]
12554 async fn the_fold_pages_past_a_full_window_to_find_the_roster_it_tracks() {
12555 let (bed, owner, _m) = TestBed::new();
12564 bed.swap_to(&owner);
12565 let community = create_community(&bed.relay, "DeepFold", bed.relays.clone(), None).await.unwrap();
12566 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12567 let rid = "e7".repeat(32);
12568 let admin = Keys::generate();
12569
12570 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12572 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
12573 follow_control(&bed.relay, &community).await.unwrap();
12574 assert!(
12575 crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12576 "baseline: the admin folds while its editions are still in reach",
12577 );
12578
12579 for i in 0..(FOLLOW_PAGE + 50) {
12581 publish_channel_edition_at(
12582 &bed.relay, &community, &owner.keys,
12583 &ChannelId(crate::simd::hex::hex_to_bytes_32(&format!("{:064x}", i + 1))),
12584 &format!("ch{i}"), 1, 2_000 + i as u64,
12585 ).await;
12586 }
12587
12588 let grant_eid = crate::simd::hex::bytes_to_hex_32(
12592 &crate::community::v2::derive::grant_locator(community.id(), &admin.public_key().to_bytes()),
12593 );
12594 crate::db::community::delete_edition_head(&cid_hex, &grant_eid).unwrap();
12595 assert!(
12596 crate::db::community::get_edition_head(&cid_hex, &grant_eid).unwrap().is_none(),
12597 "precondition: the floor is gone",
12598 );
12599
12600 follow_control(&bed.relay, &community).await.unwrap();
12601
12602 assert!(
12603 crate::db::community::get_edition_head(&cid_hex, &grant_eid).unwrap().is_some(),
12604 "the fold MUST page past the newest window and reach the grant that defines the \
12605 admin list — stopping at the first coherent-looking page is how a community \
12606 becomes unable to re-derive its own roster",
12607 );
12608 assert!(
12609 crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12610 "and the admin survives",
12611 );
12612 }
12613
12614 #[tokio::test]
12615 async fn a_grant_naming_a_role_its_own_holder_created_still_folds() {
12616 let (bed, owner, _m) = TestBed::new();
12628 bed.swap_to(&owner);
12629 let community = create_community(&bed.relay, "GrantCycle", bed.relays.clone(), None).await.unwrap();
12630 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12631 let admin = Keys::generate();
12632 let member = Keys::generate();
12633
12634 let owner_role = "aa".repeat(32);
12636 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&owner_role, Permissions::ADMIN_ALL), 1).await;
12637 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![owner_role.clone()], 1).await;
12638 follow_control(&bed.relay, &community).await.unwrap();
12639 assert!(
12640 crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12641 "the admin is promoted",
12642 );
12643
12644 let admin_made = "bb".repeat(32);
12646 let mut made = admin_role(&admin_made, Permissions::BAN);
12647 made.position = 5;
12648 publish_role(&bed.relay, &community, &admin, &made, 1).await;
12649
12650 follow_control(&bed.relay, &community).await.unwrap();
12651 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12652 assert!(
12653 roster.role(&admin_made).is_some(),
12654 "an admin-authored role folds while its author's grant is resolvable",
12655 );
12656 assert!(
12657 roster.is_admin(&admin.public_key().to_hex()),
12658 "and the admin keeps their own standing — a cycle here empties the roster",
12659 );
12660
12661 publish_grant(
12666 &bed.relay, &community, &owner.keys, &member.public_key(),
12667 vec![admin_made.clone(), "cc".repeat(32)], 1,
12668 ).await;
12669 follow_control(&bed.relay, &community).await.unwrap();
12670 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12671 let mg = roster.grants.iter().find(|g| g.member == member.public_key().to_hex());
12672 assert!(
12673 mg.is_some_and(|g| g.role_ids.contains(&admin_made)),
12674 "the resolvable half of the grant is honored",
12675 );
12676 assert!(
12677 mg.is_some_and(|g| !g.role_ids.iter().any(|r| r == &"cc".repeat(32))),
12678 "and the unresolvable half is NOT — partial resolution only ever grants less",
12679 );
12680 assert!(
12681 roster.is_admin(&admin.public_key().to_hex()),
12682 "the rest of the roster survives it",
12683 );
12684 }
12685
12686 #[tokio::test]
12687 async fn a_cold_device_pages_past_the_first_window_to_find_an_older_grant() {
12688 let (bed, owner, _m) = TestBed::new();
12694 bed.swap_to(&owner);
12695 let community = create_community(&bed.relay, "ColdPage", bed.relays.clone(), None).await.unwrap();
12696 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12697
12698 let admin = Keys::generate();
12700 let rid = "e5".repeat(32);
12701 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12702 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
12703
12704 for v in 2..(FOLLOW_PAGE as u64 + 2) {
12706 publish_filler_edition(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), v, 1_000 + v).await;
12707 }
12708
12709 follow_control(&bed.relay, &community).await.unwrap();
12710
12711 assert!(
12712 crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12713 "a cold device must page PAST the first window — stopping there caches a \
12714 partial authority as its own baseline and drops every older grant",
12715 );
12716 }
12717
12718 #[tokio::test]
12719 async fn a_truncated_cold_fold_banks_nothing_at_all() {
12720 let (bed, owner, _m) = TestBed::new();
12727 bed.swap_to(&owner);
12728 let community = create_community(&bed.relay, "Wall", bed.relays.clone(), None).await.unwrap();
12729 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12730
12731 let admin = Keys::generate();
12732 let rid = "e5".repeat(32);
12733 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12734 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
12735 for v in 2..(FOLLOW_PAGE as u64 + 2) {
12737 publish_filler_edition(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), v, 1_000).await;
12738 }
12739
12740 follow_control(&bed.relay, &community).await.unwrap();
12741
12742 assert_eq!(
12743 crate::db::community::get_community_roles_at(&cid_hex).unwrap(),
12744 0,
12745 "a truncated fold must bank NO baseline — a stored partial roster outranks \
12746 the complete fold that follows it and can never be replaced",
12747 );
12748 }
12749
12750 #[tokio::test]
12751 async fn an_older_window_never_overwrites_a_newer_cached_roster() {
12752 let (bed, owner, _m) = TestBed::new();
12756 bed.swap_to(&owner);
12757 let community = create_community(&bed.relay, "Monotone", bed.relays.clone(), None).await.unwrap();
12758 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12759
12760 let admin = Keys::generate();
12761 let rid = "e5".repeat(32);
12762 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12763 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
12764 follow_control(&bed.relay, &community).await.unwrap();
12765 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()));
12766
12767 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12769 crate::db::community::set_community_roles(&cid_hex, &roster, i64::MAX).unwrap();
12770
12771 follow_control(&bed.relay, &community).await.unwrap();
12772 assert!(
12773 crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12774 "an older window must not overwrite a newer cached roster",
12775 );
12776 assert_eq!(
12777 crate::db::community::get_community_roles_at(&cid_hex).unwrap(),
12778 i64::MAX,
12779 "and the newer provenance stamp survives — otherwise the next fold downgrades freely",
12780 );
12781 }
12782
12783 struct SlowMajority<'a>(&'a MemoryRelay, u64);
12787 #[async_trait::async_trait]
12788 impl Transport for SlowMajority<'_> {
12789 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
12790 self.0.publish(e, r).await
12791 }
12792 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
12793 let out = self.0.fetch(query, relays).await?;
12794 if query.evidence == crate::community::transport::Evidence::Full {
12795 return Ok(out);
12796 }
12797 Ok(out.into_iter().filter(|e| e.created_at.as_secs() > self.1).collect())
12798 }
12799 async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
12800 self.0.fetch_plane(plane, query, relays).await
12801 }
12802 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
12803 self.0.publish_durable(e, r).await
12804 }
12805 }
12806
12807 #[tokio::test]
12808 async fn a_live_link_a_quorum_cannot_see_still_reads_public() {
12809 let (_tmp, _guard, _owner) = init_test_db();
12815 let relay = MemoryRelay::new();
12816 let community = create_community(&relay, "QuorumBlind", vec!["wss://r".into()], None).await.unwrap();
12817 mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
12818 assert!(community_is_public(&relay, &community).await, "the link is live on the plane");
12819
12820 let slow = SlowMajority(&relay, u64::MAX);
12823 assert!(
12824 community_is_public(&slow, &community).await,
12825 "a live link only a minority relay serves must still read Public — reading \
12826 Private here refounds a Public community on ban, severing nothing",
12827 );
12828 }
12829
12830 #[tokio::test]
12831 async fn a_quorum_that_cannot_see_a_stored_entity_escalates_to_full() {
12832 let (bed, owner, _m) = TestBed::new();
12837 bed.swap_to(&owner);
12838 let community = create_community(&bed.relay, "Escalate", bed.relays.clone(), None).await.unwrap();
12839 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12840
12841 let admin = Keys::generate();
12842 let rid = "e5".repeat(32);
12843 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12844 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
12845
12846 follow_control(&bed.relay, &community).await.unwrap();
12848 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()));
12849
12850 let admin2 = Keys::generate();
12854 publish_grant(&bed.relay, &community, &owner.keys, &admin2.public_key(), vec![rid], 1).await;
12855
12856 let slow = SlowMajority(&bed.relay, 5_000);
12857 follow_control(&slow, &community).await.unwrap();
12858
12859 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12860 assert!(
12861 roster.is_admin(&admin.public_key().to_hex()),
12862 "the first admin survives — the retain is doing its job",
12863 );
12864 assert!(
12865 roster.is_admin(&admin2.public_key().to_hex()),
12866 "and the roster still ADVANCES: a Quorum read that could not account for a \
12867 stored entity must re-read at Full, or the cache freezes holding the only \
12868 surviving copy of the roster and no promotion ever lands again",
12869 );
12870 }
12871
12872 #[tokio::test]
12873 async fn one_gapped_entity_does_not_freeze_the_rest_of_the_roster() {
12874 let (bed, owner, _m) = TestBed::new();
12880 bed.swap_to(&owner);
12881 let community = create_community(&bed.relay, "OneGap", bed.relays.clone(), None).await.unwrap();
12882 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12883
12884 let admin = Keys::generate();
12886 let rid = "e5".repeat(32);
12887 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12888 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
12889
12890 let rid2 = "c7".repeat(32);
12895 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid2, Permissions::BAN), 5).await;
12896 crate::db::community::set_edition_head_at_epoch(&cid_hex, &rid2, 3, &[9u8; 32], &[9u8; 32], community.root_epoch.0).unwrap();
12897
12898 follow_control(&bed.relay, &community).await.unwrap();
12899
12900 assert!(
12901 crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12902 "a hole in ONE entity must not withhold every other entity's authority — that is \
12903 how a community that folds correctly still shows an empty roster",
12904 );
12905 }
12906
12907 #[tokio::test]
12908 async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
12909 let (bed, owner, _m) = TestBed::new();
12910 bed.swap_to(&owner);
12911 let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
12912 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12913 let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
12914 let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
12915
12916 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
12918 publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
12919 publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
12920 follow_control(&bed.relay, &community).await.unwrap().updated;
12921 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
12922
12923 let relay2 = MemoryRelay::new();
12927 publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
12928 follow_control(&relay2, &community).await.unwrap().updated;
12929 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12930 assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
12931 }
12932
12933 #[tokio::test]
12934 async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
12935 let (_tmp, _guard, owner) = init_test_db();
12940 let relay = MemoryRelay::new();
12941 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
12942 let admin = Keys::generate();
12943 let rid = "a7".repeat(32);
12944 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
12945 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
12946
12947 let group = control::ControlPlane::of(&community).write_group().unwrap();
12949 let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
12950 let rumor = control::build_edition_rumor(
12951 admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
12952 head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
12953 &serde_json::to_string(&meta).unwrap(), 1_000, None,
12954 );
12955 let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
12956 relay.publish(&wrap, &community.relays).await.unwrap();
12957
12958 let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
12959 let victim = Keys::generate().public_key().to_hex();
12960 let ban_rumor = control::build_edition_rumor(
12961 admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
12962 &serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
12963 );
12964 let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
12965 relay.publish(&ban_wrap, &community.relays).await.unwrap();
12966
12967 let updated = follow_control(&relay, &community).await.unwrap().updated;
12968 assert!(
12969 updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
12970 "an uncited metadata edit must not be honored",
12971 );
12972 let authority = fetch_authority(&relay, &community).await;
12973 assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
12974 }
12981
12982 #[tokio::test]
12983 async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
12984 let (_tmp, _guard, owner) = init_test_db();
12988 let relay = MemoryRelay::new();
12989 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
12990 let admin = Keys::generate();
12991 let rid = "a1".repeat(32);
12992 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
12993 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
12994 publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
12995
12996 let updated = follow_control(&relay, &community).await.unwrap().updated.expect("admin edit authorized");
12997 assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
12998
12999 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
13001 let _ = follow_control(&relay, &community).await.unwrap().updated;
13002 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13003 assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
13004 }
13005
13006 #[tokio::test]
13007 async fn a_roleless_member_cannot_edit_metadata() {
13008 let (_tmp, _guard, _owner) = init_test_db();
13009 let relay = MemoryRelay::new();
13010 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
13011 let stranger = Keys::generate();
13012 publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
13013 assert!(
13014 follow_control(&relay, &community).await.unwrap().updated.is_none(),
13015 "a roleless member's metadata edit never folds"
13016 );
13017 }
13018
13019 #[tokio::test]
13020 async fn a_self_signed_grant_is_not_authority() {
13021 let (_tmp, _guard, _owner) = init_test_db();
13025 let relay = MemoryRelay::new();
13026 let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
13027 let rogue = Keys::generate();
13028 let rid = "b2".repeat(32);
13029 publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
13030 publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
13031 publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
13032 assert!(
13033 follow_control(&relay, &community).await.unwrap().updated.is_none(),
13034 "a self-signed grant confers no authority"
13035 );
13036 }
13037
13038 #[tokio::test]
13039 async fn the_banlist_is_enforced_only_from_a_ban_holder() {
13040 let (_tmp, _guard, owner) = init_test_db();
13041 let relay = MemoryRelay::new();
13042 let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
13043 let target = "cc".repeat(32);
13044
13045 let rogue = Keys::generate();
13047 publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
13048 let floors = load_floors(&community);
13049 let editions = fetch_control(&relay, &community).await;
13050 let authority = fold_authority(&community, &editions, &floors);
13051 assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
13052
13053 publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
13055 let editions = fetch_control(&relay, &community).await;
13056 let authority = fold_authority(&community, &editions, &floors);
13057 assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
13058 }
13059
13060 #[tokio::test]
13061 async fn a_banned_admin_loses_all_authority() {
13062 let (_tmp, _guard, owner) = init_test_db();
13065 let relay = MemoryRelay::new();
13066 let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
13067 let admin = Keys::generate();
13068 let rid = "e5".repeat(32);
13069 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
13070 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
13071 publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
13073
13074 assert!(
13075 follow_control(&relay, &community).await.unwrap().updated.is_none(),
13076 "a banned admin's edit is dropped even with an unstripped grant"
13077 );
13078 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
13079 assert!(authority.banned.contains(&admin.public_key().to_hex()));
13080 assert!(
13081 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
13082 "a banned admin holds no bit"
13083 );
13084 }
13085
13086 #[tokio::test]
13087 async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
13088 let (_tmp, _guard, owner) = init_test_db();
13092 let relay = MemoryRelay::new();
13093 let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
13094 let admin = Keys::generate();
13095 let moder = Keys::generate();
13096 let stranger = Keys::generate();
13097 let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
13098 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, extra: Default::default() }, 1).await;
13099 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, extra: Default::default() }, 1).await;
13100 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
13101 publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
13102 publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
13103
13104 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
13105 assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
13106 assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
13107 assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
13108 }
13109
13110 #[tokio::test]
13111 async fn an_unauthorized_higher_banlist_cannot_unban() {
13112 let (_tmp, _guard, owner) = init_test_db();
13116 let relay = MemoryRelay::new();
13117 let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
13118 let target = "cc".repeat(32);
13119 publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
13120 follow_control(&relay, &community).await.unwrap().updated; let rogue = Keys::generate();
13123 publish_banlist(&relay, &community, &rogue, &[], 2).await; let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
13125 assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
13126 }
13127
13128 #[tokio::test]
13129 async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
13130 let (_tmp, _guard, _owner) = init_test_db();
13133 let relay = MemoryRelay::new();
13134 let relays = vec!["wss://r".to_string()];
13135 let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
13136 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
13137 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
13138
13139 let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
13140 assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
13141 assert_eq!(rehydrated[0].id().0, community.id().0);
13142 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
13143 }
13144
13145 #[tokio::test]
13146 async fn a_retried_leave_tombstone_cannot_bury_a_rejoin_made_meanwhile() {
13147 let (_tmp, _guard, _owner) = init_test_db();
13148 let relay = MemoryRelay::new();
13149 let relays = vec!["wss://r".to_string()];
13150 let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
13151 let cid = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13152
13153 let left_at = now_ms() - 1000;
13155 tombstone_community_list(&relay, community.id(), &relays, left_at).await.unwrap();
13156 republish_community_list(&relay, Some(community.id())).await.unwrap();
13158 assert!(fetch_fragments(&relay, &relays).await.unwrap().unwrap().list.is_live(&cid));
13159
13160 tombstone_community_list(&relay, community.id(), &relays, left_at).await.unwrap();
13162 let set = fetch_fragments(&relay, &relays).await.unwrap().unwrap();
13163 assert!(set.list.is_live(&cid), "a retry re-stamping `now` would have buried the rejoin");
13164 }
13165
13166 #[tokio::test]
13167 async fn a_boot_with_no_fragments_seeds_the_list_from_local_state() {
13168 let (_tmp, _guard, _owner) = init_test_db();
13169 let relay = MemoryRelay::new();
13170 let relays = vec!["wss://r".to_string()];
13171 let community = create_community(&relay, "Seeded", relays.clone(), None).await.unwrap();
13172
13173 let bare = MemoryRelay::new();
13176 sync_community_list(&bare, &relays).await.unwrap();
13177 let set = fetch_fragments(&bare, &relays).await.unwrap().expect("boot seeded the List");
13178 let live = set.list.live_entries();
13179 assert_eq!(live.len(), 1);
13180 assert_eq!(live[0].community_id, crate::simd::hex::bytes_to_hex_32(&community.id().0));
13181
13182 let empty = MemoryRelay::new();
13185 sync_community_list(&empty, &relays).await.unwrap();
13186 assert_eq!(empty.stored_count(), 0, "the seed runs once");
13187 }
13188
13189 #[tokio::test]
13190 async fn a_dissolved_invite_retires_on_accept_and_tombstones_the_list() {
13191 let (bed, owner, member) = TestBed::new();
13195 bed.swap_to(&owner);
13196 let community = create_community(&bed.relay, "Grave", bed.relays.clone(), None).await.unwrap();
13197 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
13198 dissolve_community(&bed.relay, &community).await.unwrap();
13199
13200 bed.swap_to(&member);
13201 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13202 crate::db::community::save_pending_invite(&cid_hex, &bundle, "inviter", i64::MAX).unwrap();
13203 let err = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap_err();
13204 assert_eq!(err, ERR_DISSOLVED, "the grave refusal is the sentinel the boundary matches");
13205
13206 retire_dead_invite(&bed.relay, &cid_hex, &bed.relays).await;
13207 assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_none(), "the dead invite leaves this device");
13208 let set = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("the tombstone published");
13209 assert!(set.list.tombstones.iter().any(|t| t.community_id == cid_hex), "and siblings get the §8 tombstone");
13210 assert!(!set.list.is_live(&cid_hex));
13211 }
13212
13213 #[tokio::test]
13214 async fn an_owner_dissolve_tombstones_the_list_for_sibling_devices() {
13215 let (bed, owner, _member) = TestBed::new();
13220 bed.swap_to(&owner);
13221 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
13222 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13223 let before = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("create published the List");
13224 assert!(before.list.is_live(&cid_hex), "the membership is live before the dissolve");
13225
13226 dissolve_community(&bed.relay, &community).await.unwrap();
13227
13228 let set = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("the List outlives the dissolve");
13229 assert!(
13230 set.list.tombstones.iter().any(|t| t.community_id == cid_hex),
13231 "the dissolve published the §8 tombstone alongside the grave"
13232 );
13233 assert!(!set.list.is_live(&cid_hex), "sibling devices tear the husk down on their next list sync");
13234 }
13235
13236 #[tokio::test]
13237 async fn a_sibling_devices_list_tombstone_purges_the_parked_invite() {
13238 let (_tmp, _guard, _owner) = init_test_db();
13242 let relay = MemoryRelay::new();
13243 let relays = vec!["wss://r".to_string()];
13244 let cid = crate::community::CommunityId([0x5A; 32]);
13245 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
13246
13247 tombstone_community_list(&relay, &cid, &relays, now_ms() - 600_000).await.unwrap();
13249 crate::db::community::save_pending_invite(&cid_hex, "{}", "inviter", i64::MAX).unwrap();
13250 sync_community_list(&relay, &relays).await.unwrap();
13251 assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_some(), "a re-invite supersedes a past leave");
13252
13253 tombstone_community_list(&relay, &cid, &relays, now_ms() + 10_000).await.unwrap();
13255 sync_community_list(&relay, &relays).await.unwrap();
13256 assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_none(), "the sibling's removal clears the parked copy");
13257 }
13258
13259 #[tokio::test]
13260 async fn an_unchanged_republish_rewrites_no_fragment() {
13261 let (_tmp, _guard, _owner) = init_test_db();
13265 let relay = MemoryRelay::new();
13266 let relays = vec!["wss://r".to_string()];
13267 create_community(&relay, "Stable", relays.clone(), None).await.unwrap();
13268 let before = fetch_fragments(&relay, &relays).await.unwrap().unwrap().created_at;
13269
13270 republish_community_list(&relay, None).await.unwrap();
13271 let after = fetch_fragments(&relay, &relays).await.unwrap().unwrap().created_at;
13272 assert_eq!(before, after, "a byte-identical fragment must not republish");
13273 }
13274
13275 #[tokio::test]
13276 async fn a_frags_tie_at_equal_age_resolves_to_the_larger_count() {
13277 let (_tmp, _guard, owner) = init_test_db();
13281 let relay = MemoryRelay::new();
13282 let relays = vec!["wss://r".to_string()];
13283 let at = now_ms() / 1000;
13284 let two = super::super::list_frag::FragList { frags: 2, entries: vec![], tombstones: vec![], extra: Default::default() };
13285 let three = super::super::list_frag::FragList { frags: 3, entries: vec![], tombstones: vec![], extra: Default::default() };
13286 for (frag, index) in [(&three, 0usize), (&two, 1usize)] {
13289 let e = super::super::list_frag::build_fragment_event_keys(&owner, frag, index, at).unwrap();
13290 relay.publish(&e, &relays).await.unwrap();
13291 }
13292 let set = fetch_fragments(&relay, &relays).await.unwrap().unwrap();
13293 assert_eq!(set.declared, 3, "an age tie resolves to the larger frags");
13294 assert!(!set.is_complete(), "index 2 is unread, so the set refuses to write");
13295 }
13296
13297 struct HalfDeaf<'a>(&'a MemoryRelay, String);
13300 #[async_trait::async_trait]
13301 impl Transport for HalfDeaf<'_> {
13302 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
13303 self.0.publish(e, r).await
13304 }
13305 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
13306 if relays.len() == 1 && relays[0] == self.1 {
13307 return Err("relay did not answer the fetch".to_string());
13308 }
13309 self.0.fetch(query, relays).await
13310 }
13311 async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
13312 self.0.fetch_plane(plane, query, relays).await
13313 }
13314 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
13315 self.0.publish_durable(e, r).await
13316 }
13317 }
13318
13319 #[tokio::test]
13320 async fn an_unconfirmed_empty_read_never_seeds() {
13321 let (_tmp, _guard, _owner) = init_test_db();
13327 let relay = MemoryRelay::new();
13328 let relays = vec!["wss://r".to_string(), "wss://dead".to_string()];
13329 let community = create_community(&relay, "Gated", relays.clone(), None).await.unwrap();
13330 let cid = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13331
13332 let bare = MemoryRelay::new();
13333 let half_deaf = HalfDeaf(&bare, "wss://dead".to_string());
13334 sync_community_list(&half_deaf, &relays).await.unwrap();
13335 assert_eq!(bare.stored_count(), 0, "a failed read must never seed");
13336 assert!(
13337 crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).unwrap().is_none(),
13338 "a deferred seed must not latch"
13339 );
13340
13341 sync_community_list(&bare, &relays).await.unwrap();
13343 let set = fetch_fragments(&bare, &relays).await.unwrap().expect("the confirmed-empty boot seeds");
13344 assert!(set.list.is_live(&cid));
13345 assert!(crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).unwrap().is_some());
13346 }
13347
13348 #[tokio::test]
13349 async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
13350 let (_tmp, _guard, _owner) = init_test_db();
13351 let relay = MemoryRelay::new();
13352 let relays = vec!["wss://r".to_string()];
13353 let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
13354 leave_community(&relay, &community).await.unwrap(); let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
13357 assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
13358 }
13359
13360 #[tokio::test]
13361 async fn accepting_the_same_bundle_twice_is_idempotent() {
13362 let (bed, owner, member) = TestBed::new();
13366 bed.swap_to(&owner);
13367 let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
13368 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
13369 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13370 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
13371
13372 bed.swap_to(&member);
13373 let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
13374 let channels_after_first = first.channels.len();
13375 let root_after_first = first.community_root;
13376
13377 let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
13379 assert_eq!(second.id().0, first.id().0, "same community_id");
13380 assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
13381 assert_eq!(second.community_root, root_after_first, "root unchanged");
13382
13383 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13385 assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
13386 assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
13387 }
13388
13389 #[tokio::test]
13390 async fn a_fresh_joiner_can_authorize_a_moderators_hide_before_any_follow() {
13391 let (bed, owner, member) = TestBed::new();
13398 let admin = Keys::generate();
13399 bed.swap_to(&owner);
13400 let community = create_community(&bed.relay, "Moderated", bed.relays.clone(), None).await.unwrap();
13401 grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
13402 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
13403
13404 bed.swap_to(&member);
13407 let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
13408 let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
13409 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
13410
13411 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
13412 assert!(!roster.grants.is_empty(), "the join persisted the roster it already folded");
13413 let owner_hex = joined.owner().unwrap().to_hex();
13414 let victim = Keys::generate().public_key().to_hex();
13415 assert!(
13416 crate::community::moderation::can_hide(Some(&owner_hex), &roster, &admin.public_key().to_hex(), &victim),
13417 "the admin's hides are honorable on the FIRST history ingest, not after a follow"
13418 );
13419 let grant_hex = crate::simd::hex::bytes_to_hex_32(&crate::community::v2::derive::grant_locator(
13422 joined.id(),
13423 &admin.public_key().to_bytes(),
13424 ));
13425 assert!(
13426 crate::db::community::get_edition_head(&cid_hex, &grant_hex).unwrap().is_some(),
13427 "the admin-signed grant head survived the join"
13428 );
13429 }
13430
13431 #[tokio::test]
13432 async fn a_severed_member_can_be_unbanned_and_re_admitted() {
13433 let (bed, owner, member) = TestBed::new();
13438 bed.swap_to(&owner);
13439 let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
13440 let general = community.channels[0].id;
13441 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
13442 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
13443
13444 bed.swap_to(&member);
13445 let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
13446 let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
13447 assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
13448
13449 bed.swap_to(&owner);
13451 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
13452 grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
13453 community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
13454 assert_eq!(community.root_epoch, Epoch(1));
13455 send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
13456
13457 bed.swap_to(&member);
13459 let session = crate::db::current_session();
13460 assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
13461
13462 bed.swap_to(&owner);
13466 set_banlist(&bed.relay, &community, &[]).await.unwrap();
13467 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13468 assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
13469 let fresh_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
13470
13471 bed.swap_to(&member);
13473 let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
13474 assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
13475 assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
13476 let seen = texts_in(&bed.relay, &rejoined, &general).await;
13477 assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
13478 send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
13479
13480 bed.swap_to(&owner);
13481 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13482 assert!(
13483 texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
13484 "the re-admitted member converses again at the new epoch"
13485 );
13486 let members = memberlist(&bed.relay, &community).await.unwrap();
13488 assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
13489 }
13490
13491 #[tokio::test]
13492 async fn dissolution_blocks_a_join() {
13493 let (bed, owner, member) = TestBed::new();
13496 bed.swap_to(&owner);
13497 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
13498 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13499 let bundle_json = serde_json::to_string(&bundle).unwrap();
13500 dissolve_community(&bed.relay, &community).await.unwrap();
13501 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
13502
13503 bed.swap_to(&member);
13504 let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
13505 assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
13506 }
13507
13508 #[tokio::test]
13509 async fn dissolution_seals_writes_but_not_reads() {
13510 let (bed, owner, _member) = TestBed::new();
13513 bed.swap_to(&owner);
13514 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
13515 let general = community.channels[0].id;
13516 send_message(&bed.relay, &community, &general, "before the end").await.unwrap();
13517
13518 dissolve_community(&bed.relay, &community).await.unwrap();
13519 let sealed = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13520
13521 for err in [
13522 send_message(&bed.relay, &sealed, &general, "after the end").await.unwrap_err(),
13523 send_reaction(&bed.relay, &sealed, &general, &"a".repeat(64), &"b".repeat(64), crate::community::v2::kind::MESSAGE, "+", None)
13524 .await
13525 .unwrap_err(),
13526 send_edit(&bed.relay, &sealed, &general, &"a".repeat(64), "revised", &[]).await.unwrap_err(),
13527 ] {
13528 assert!(err.contains("dissolved"), "every write is refused, got: {err}");
13529 }
13530 assert!(
13531 texts_in(&bed.relay, &sealed, &general).await.contains(&"before the end".to_string()),
13532 "but the history still reads"
13533 );
13534 }
13535
13536 #[tokio::test]
13537 async fn only_the_owner_can_dissolve() {
13538 let (bed, owner, member) = TestBed::new();
13539 bed.swap_to(&owner);
13540 let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
13541 bed.swap_to(&member);
13542 assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
13543 assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
13544 }
13545
13546 #[tokio::test]
13547 async fn a_foreign_tombstone_is_not_death() {
13548 let (_tmp, _guard, _owner) = init_test_db();
13551 let relay = MemoryRelay::new();
13552 let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
13553 let rogue = Keys::generate();
13554 let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
13555 let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
13556 relay.publish(&wrap, &community.relays).await.unwrap();
13557 assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
13558 }
13559
13560 #[tokio::test]
13561 async fn a_public_channel_reads_history_across_a_refounding() {
13562 let (_tmp, _guard, _owner) = init_test_db();
13566 let relay = MemoryRelay::new();
13567 let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
13568 let general = community.channels[0].id;
13569 send_message(&relay, &community, &general, "before the refounding").await.unwrap();
13570
13571 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
13572 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
13573 send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
13574
13575 let open = texts_in(&relay, &refounded, &general).await;
13576 assert!(open.contains(&"after the refounding".to_string()), "opening reads the live epoch");
13577 assert!(
13578 !open.contains(&"before the refounding".to_string()),
13579 "and NOT the rotated-past one — that plane is the back-paging cursor's job"
13580 );
13581 let paged = all_texts_in(&relay, &refounded, &general).await;
13582 assert!(paged.contains(&"before the refounding".to_string()), "the epoch-0 message stays reachable by paging");
13583 assert!(paged.contains(&"after the refounding".to_string()), "alongside the epoch-1 one");
13584 }
13585
13586 #[tokio::test]
13587 async fn refounding_aborts_when_control_state_is_withheld() {
13588 let (_tmp, _guard, owner) = init_test_db();
13592 let relay = MemoryRelay::new();
13593 let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
13594 publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
13595 follow_control(&relay, &community).await.unwrap().updated; let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13600 moved.relays = vec!["wss://empty".into()];
13601 crate::db::community::save_community_v2(&moved).unwrap();
13602
13603 let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
13604 assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
13605 assert_eq!(
13606 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
13607 Epoch(0),
13608 "the epoch did NOT advance (zero published state)"
13609 );
13610 }
13611
13612 #[tokio::test]
13613 async fn refounding_rolls_the_root_and_severs_a_removed_member() {
13614 let (bed, owner, member) = TestBed::new();
13617 bed.swap_to(&owner);
13618 let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
13619 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13620 let bundle_json = serde_json::to_string(&bundle).unwrap();
13621 bed.swap_to(&member);
13622 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13623
13624 bed.swap_to(&owner);
13625 let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
13626 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
13627 assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
13628 assert_eq!(
13630 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
13631 Epoch(1),
13632 "the owner committed the new epoch"
13633 );
13634
13635 bed.swap_to(&member);
13639 let session = crate::db::current_session();
13640 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
13641 assert!(follow.self_removed, "the removed member is cut by the re-founding");
13642 }
13643
13644 #[tokio::test]
13645 async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
13646 let (bed, owner, member) = TestBed::new();
13651 bed.swap_to(&owner);
13652 let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
13653 let rid = "b0".repeat(32);
13654 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13655 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
13656 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13657 let bundle_json = serde_json::to_string(&bundle).unwrap();
13658 bed.swap_to(&member);
13659 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13660 let _ = follow_control(&bed.relay, &joined).await;
13663 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13664 assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
13666 assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
13668 }
13669
13670 #[tokio::test]
13671 async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
13672 let (bed, owner, me) = TestBed::new();
13677 let admin = Keys::generate();
13678 bed.swap_to(&owner);
13679 let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
13680 let rid = "b0".repeat(32);
13681 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13682 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
13683
13684 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13686 let bundle_json = serde_json::to_string(&bundle).unwrap();
13687 bed.swap_to(&me);
13688 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13689 let _ = follow_control(&bed.relay, &joined).await;
13690 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13691
13692 let new_root = [0xC7; 32];
13695 publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13696
13697 let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
13698 .expect("an authorized admin's Refounding is adopted");
13699 assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
13700 assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
13701 }
13702
13703 #[tokio::test]
13704 async fn adopting_a_severing_rotation_revokes_my_own_links_instead_of_refreshing_them() {
13705 let (bed, owner, me) = TestBed::new();
13711 bed.swap_to(&owner);
13712 let community = create_community(&bed.relay, "SeverHeal", bed.relays.clone(), None).await.unwrap();
13713 let rid = "b1".repeat(32);
13714 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13715 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13716
13717 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13718 let bundle_json = serde_json::to_string(&bundle).unwrap();
13719 bed.swap_to(&me);
13720 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13721 let _ = follow_control(&bed.relay, &joined).await;
13722 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13723
13724 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13725 assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok(), "my link is live before the raid");
13726
13727 let new_root = [0xD5; 32];
13729 publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13730
13731 let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
13732 .expect("the owner's severing Refounding is adopted");
13733 assert_eq!(updated.root_epoch, Epoch(1), "I still advance to the new epoch");
13734
13735 let err = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err();
13736 assert!(err.contains("revoked"), "my link must be a grave, not a refreshed door: {err}");
13737 assert!(
13738 crate::db::community::list_public_invites(&crate::simd::hex::bytes_to_hex_32(&updated.id().0)).unwrap().is_empty(),
13739 "and the local record goes with it",
13740 );
13741 }
13742
13743 #[tokio::test(flavor = "multi_thread")]
13744 async fn a_severing_adopt_never_deadlocks_the_last_link_creator() {
13745 let (bed, owner, me) = TestBed::new();
13752 bed.swap_to(&owner);
13753 let community = create_community(&bed.relay, "NoDeadlock", bed.relays.clone(), None).await.unwrap();
13754 let rid = "b2".repeat(32);
13755 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13756 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13757 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13758 let bundle_json = serde_json::to_string(&bundle).unwrap();
13759 bed.swap_to(&me);
13760 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13761 let _ = follow_control(&bed.relay, &joined).await;
13762 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13763 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13766 assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok());
13767
13768 let new_root = [0xD6; 32];
13769 publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13770
13771 let followed = crate::rt::time::timeout(
13772 std::time::Duration::from_secs(20),
13773 follow_rekeys(&bed.relay, &joined, &crate::db::current_session()),
13774 )
13775 .await
13776 .expect("the severing follow must not deadlock on follow_lock")
13777 .unwrap();
13778 assert_eq!(followed.updated.expect("adopted").root_epoch, Epoch(1));
13779 }
13780
13781 #[tokio::test(flavor = "multi_thread")]
13782 async fn a_severing_adopt_spares_a_link_minted_after_the_raid() {
13783 let (bed, owner, me) = TestBed::new();
13787 bed.swap_to(&owner);
13788 let community = create_community(&bed.relay, "FreshDoor", bed.relays.clone(), None).await.unwrap();
13789 let rid = "b3".repeat(32);
13790 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13791 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13792 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13793 let bundle_json = serde_json::to_string(&bundle).unwrap();
13794 bed.swap_to(&me);
13795 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13796 let _ = follow_control(&bed.relay, &joined).await;
13797 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13798 let raid_era = mint_public_link(&bed.relay, &joined, "https://old", None, None).await.unwrap();
13799
13800 let new_root = [0xD7; 32];
13801 publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13802 let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated.expect("adopted");
13803 assert!(
13804 fetch_public_bundle(&bed.relay, &raid_era.url).await.unwrap_err().contains("revoked"),
13805 "the raid-era link is buried",
13806 );
13807
13808 let fresh = mint_public_link(&bed.relay, &updated, "https://new", None, None).await.unwrap();
13812 let _ = follow_rekeys(&bed.relay, &updated, &crate::db::current_session()).await.unwrap();
13813 assert!(
13814 fetch_public_bundle(&bed.relay, &fresh.url).await.is_ok(),
13815 "a link minted after the containment survives later follow passes",
13816 );
13817 }
13818
13819 #[tokio::test(flavor = "multi_thread")]
13820 async fn an_unauthorized_severing_rotation_never_touches_my_links() {
13821 let (bed, owner, me) = TestBed::new();
13825 bed.swap_to(&owner);
13826 let community = create_community(&bed.relay, "NoForge", bed.relays.clone(), None).await.unwrap();
13827 let rid = "b4".repeat(32);
13828 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::CREATE_INVITE), 1).await;
13829 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13830 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13831 let bundle_json = serde_json::to_string(&bundle).unwrap();
13832 bed.swap_to(&me);
13833 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13834 let _ = follow_control(&bed.relay, &joined).await;
13835 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13836 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13837
13838 let stranger = Keys::generate();
13840 publish_severing_base_rotation(&bed.relay, &joined, &stranger, &[me.keys.public_key(), owner.keys.public_key()], &[0xD8; 32], &joined.community_root).await;
13841
13842 let followed = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
13843 assert!(followed.updated.is_none(), "an unauthorized rotation is not adopted");
13844 assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok(), "and my link is untouched");
13845 }
13846
13847 #[tokio::test(flavor = "multi_thread")]
13848 async fn a_degraded_plane_read_is_not_evidence_of_absence() {
13849 use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
13857 let relay = AdversarialRelay::new();
13858 let (bed, owner, me) = TestBed::new();
13859 bed.swap_to(&owner);
13860 let community = create_community(relay.relay(), "Coverage", bed.relays.clone(), None).await.unwrap();
13861 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13862 let bundle_json = serde_json::to_string(&bundle).unwrap();
13863 bed.swap_to(&me);
13864 let joined = accept_parked_invite(relay.relay(), &bundle_json, None).await.unwrap();
13865 let _ = follow_control(relay.relay(), &joined).await;
13866 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13867
13868 bed.swap_to(&owner);
13869 publish_base_rotation(relay.relay(), &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &[0xE9; 32], &joined.community_root).await;
13870 bed.swap_to(&me);
13871
13872 let plane = base_rekey_group_key(&joined.community_root, joined.id(), Epoch(1));
13874 relay.on(Match::author(&plane.pk_hex()), Fault::FailFetch("relay down".into()));
13875 let _ = follow_rekeys(&relay, &joined, &crate::db::current_session()).await;
13876 let during = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13877 assert_eq!(
13878 during.root_epoch, joined.root_epoch,
13879 "an unreachable plane must not advance anything — but it must also not be \
13880 mistaken for a community that stopped rotating",
13881 );
13882
13883 relay.heal();
13885 let _ = follow_rekeys(&relay, &during, &crate::db::current_session()).await;
13886 let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13887 assert_eq!(
13888 after.root_epoch, Epoch(1),
13889 "once a relay answers, the rotation it was holding is adopted — the earlier \
13890 silence was never evidence",
13891 );
13892 }
13893
13894 #[tokio::test(flavor = "multi_thread")]
13895 async fn an_older_banlist_edition_can_never_un_ban() {
13896 let (bed, owner, _me) = TestBed::new();
13902 bed.swap_to(&owner);
13903 let community = create_community(&bed.relay, "BanMonotonic", bed.relays.clone(), None).await.unwrap();
13904 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13905 let villain_keys = Keys::generate();
13906 let villain = villain_keys.public_key().to_hex();
13907
13908 crate::db::community::set_community_banlist(&cid_hex, &[villain.clone()], 5).unwrap();
13909 assert!(crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain));
13910
13911 crate::db::community::set_community_banlist(&cid_hex, &[], 3).unwrap();
13913 assert!(
13914 crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain),
13915 "a lower-versioned edition must not un-ban",
13916 );
13917 assert!(
13918 crate::db::community::is_author_banned(&cid_hex, &villain_keys.public_key()),
13919 "and the hot cache must not hold the list we declined to persist",
13920 );
13921
13922 crate::db::community::set_community_banlist(&cid_hex, &[], 6).unwrap();
13924 assert!(
13925 !crate::db::community::is_author_banned(&cid_hex, &villain_keys.public_key()),
13926 "a newer edition still lifts a ban — monotonic, not frozen",
13927 );
13928 }
13929
13930 #[tokio::test(flavor = "multi_thread")]
13931 async fn a_withholding_relay_cannot_wipe_the_banlist_or_the_roster() {
13932 use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
13940 let relay = AdversarialRelay::new();
13941 let (bed, owner, _me) = TestBed::new();
13942 bed.swap_to(&owner);
13943 let community = create_community(relay.relay(), "WitholdWipe", bed.relays.clone(), None).await.unwrap();
13944 let rid = "c4".repeat(32);
13945 let admin = Keys::generate();
13946 publish_role(relay.relay(), &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13947 publish_grant(relay.relay(), &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
13948 let villain = Keys::generate();
13949 set_banlist(relay.relay(), &community, &[villain.public_key().to_hex()]).await.unwrap();
13950 let _ = follow_control(&relay, &community).await;
13951
13952 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13953 assert!(
13954 crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain.public_key().to_hex()),
13955 "the ban folded",
13956 );
13957 let roster_before = crate::db::community::get_community_roles(&cid_hex).unwrap();
13958 assert!(!roster_before.grants.is_empty(), "the admin's grant folded");
13959
13960 let control_pk = control::ControlPlane::of(&community).pk_hex();
13962 relay.on(Match::author(&control_pk), Fault::Empty);
13963 let _ = follow_control(&relay, &community).await;
13964
13965 assert!(
13966 crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain.public_key().to_hex()),
13967 "an empty control window must NEVER un-ban — that is the fail-open direction",
13968 );
13969 let roster_after = crate::db::community::get_community_roles(&cid_hex).unwrap();
13970 assert_eq!(
13971 roster_after.grants.len(), roster_before.grants.len(),
13972 "and must never strip standing: an empty fold is absence, not a demotion",
13973 );
13974 }
13975
13976 #[tokio::test(flavor = "multi_thread")]
13977 async fn a_banned_rotators_refounding_is_never_honored() {
13978 let (bed, owner, me) = TestBed::new();
13985 bed.swap_to(&owner);
13986 let community = create_community(&bed.relay, "BannedRotator", bed.relays.clone(), None).await.unwrap();
13987 let rid = "a9".repeat(32);
13988 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13989 let rogue = Keys::generate();
13990 publish_grant(&bed.relay, &community, &owner.keys, &rogue.public_key(), vec![rid], 1).await;
13991 let _ = follow_control(&bed.relay, &community).await;
13992
13993 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13994 let bundle_json = serde_json::to_string(&bundle).unwrap();
13995 bed.swap_to(&me);
13996 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13997 let _ = follow_control(&bed.relay, &joined).await;
13998 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13999
14000 bed.swap_to(&owner);
14002 set_banlist(&bed.relay, &community, &[rogue.public_key().to_hex()]).await.unwrap();
14003 bed.swap_to(&me);
14004 let _ = follow_control(&bed.relay, &joined).await;
14005 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14006 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
14007 assert!(
14008 crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&rogue.public_key().to_hex()),
14009 "the ban folded before the rotation arrives",
14010 );
14011
14012 bed.swap_to(&owner);
14014 publish_base_rotation(
14015 &bed.relay, &joined, &rogue,
14016 &[owner.keys.public_key(), me.keys.public_key(), rogue.public_key()],
14017 &[0xBA; 32], &joined.community_root,
14018 ).await;
14019
14020 bed.swap_to(&me);
14021 let held_before = joined.root_epoch;
14022 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14023 assert!(!follow.self_removed, "a banned rotator can never remove anyone");
14024 let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14025 assert_eq!(
14026 after.root_epoch, held_before,
14027 "a banned member's rotation must not move the epoch — honoring it hands the community to the party just evicted",
14028 );
14029 assert_eq!(after.community_root, joined.community_root, "and must not move the root");
14030 }
14031
14032 #[tokio::test(flavor = "multi_thread")]
14033 async fn a_rekey_exclusion_keeps_my_epoch_keys() {
14034 let (bed, owner, me) = TestBed::new();
14041 bed.swap_to(&owner);
14042 let community = create_community(&bed.relay, "ExclusionKeys", bed.relays.clone(), None).await.unwrap();
14043 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14044 let bundle_json = serde_json::to_string(&bundle).unwrap();
14045 bed.swap_to(&me);
14046 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14047 let _ = follow_control(&bed.relay, &joined).await;
14048 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14049 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
14050 assert!(
14051 !crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(),
14052 "the joiner archived its epoch key",
14053 );
14054
14055 bed.swap_to(&owner);
14057 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key()], &[0xD1; 32], &joined.community_root).await;
14058
14059 bed.swap_to(&me);
14060 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14061 assert!(follow.self_removed, "an authorized rotation that excluded me IS a removal");
14062
14063 crate::db::community::delete_community_retain_keys(&cid_hex).unwrap();
14065 assert!(
14066 crate::db::community::load_community_v2(joined.id()).unwrap().is_none(),
14067 "the community is gone",
14068 );
14069 assert!(
14070 !crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(),
14071 "but my epoch keys survive, so I can still self-delete what I wrote",
14072 );
14073 }
14074
14075 #[tokio::test(flavor = "multi_thread")]
14076 async fn a_client_that_adopted_the_losing_fork_converges_down_onto_the_canonical_one() {
14077 let (bed, owner, me) = TestBed::new();
14085 bed.swap_to(&owner);
14086 let community = create_community(&bed.relay, "ForkHeal", bed.relays.clone(), None).await.unwrap();
14087 let rid = "f1".repeat(32);
14088 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14089 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
14090 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14091 let bundle_json = serde_json::to_string(&bundle).unwrap();
14092 bed.swap_to(&me);
14093 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14094 let _ = follow_control(&bed.relay, &joined).await;
14095 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14096
14097 let rival = Keys::generate();
14103 bed.swap_to(&owner);
14104 publish_grant(&bed.relay, &community, &owner.keys, &rival.public_key(), vec!["f1".repeat(32)], 1).await;
14105 let _ = follow_control(&bed.relay, &community).await;
14109 bed.swap_to(&me);
14110 let _ = follow_control(&bed.relay, &joined).await;
14111 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14112
14113 let low = [0x11u8; 32];
14114 let high = [0xF1u8; 32];
14115 let recipients = [owner.keys.public_key(), me.keys.public_key(), rival.public_key()];
14116 bed.swap_to(&owner);
14117 publish_base_rotation(&bed.relay, &joined, &owner.keys, &recipients, &high, &joined.community_root).await;
14118
14119 bed.swap_to(&me);
14121 let session = crate::db::current_session();
14122 let _ = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
14123 let forked = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14124 assert_eq!(forked.root_epoch, Epoch(1), "adopted the sibling we could see");
14125 assert_eq!(forked.community_root, high, "and it is the LOSING branch");
14126
14127 bed.swap_to(&owner);
14130 publish_base_rotation(&bed.relay, &joined, &rival, &recipients, &low, &joined.community_root).await;
14131
14132 bed.swap_to(&me);
14133 let _ = follow_rekeys(&bed.relay, &forked, &session).await.unwrap();
14134 let healed = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14135 assert_eq!(
14136 healed.community_root, low,
14137 "a settled epoch MUST converge down onto the canonical sibling, or the community stays split",
14138 );
14139 assert_eq!(healed.root_epoch, Epoch(1), "same epoch — this is a sideways step, not an advance");
14140
14141 let _ = follow_rekeys(&bed.relay, &healed, &session).await.unwrap();
14144 let settled = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14145 assert_eq!(settled.community_root, low, "convergence is down-only and stable");
14146 }
14147
14148 #[tokio::test(flavor = "multi_thread")]
14149 async fn a_deep_catch_up_never_loses_ground_and_lands_on_the_head() {
14150 use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
14160 const HOPS: u64 = 12;
14161
14162 let relay = AdversarialRelay::new();
14163 let (bed, owner, me) = TestBed::new();
14164 bed.swap_to(&owner);
14165 let community = create_community(relay.relay(), "DeepStorm", bed.relays.clone(), None).await.unwrap();
14166 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14167 let bundle_json = serde_json::to_string(&bundle).unwrap();
14168 bed.swap_to(&me);
14169 let joined = accept_parked_invite(relay.relay(), &bundle_json, None).await.unwrap();
14170 let _ = follow_control(relay.relay(), &joined).await;
14171 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14172
14173 bed.swap_to(&owner);
14176 let recipients = [owner.keys.public_key(), me.keys.public_key()];
14177 let mut cur = community.clone();
14178 let mut planes: Vec<String> = Vec::new();
14179 for i in 1..=HOPS {
14180 let root = [(0x40 + i) as u8; 32];
14181 planes.push(base_rekey_group_key(&cur.community_root, cur.id(), Epoch(i)).pk_hex());
14182 publish_base_rotation(relay.relay(), &cur, &owner.keys, &recipients, &root, &cur.community_root).await;
14183 cur.community_root = root;
14184 cur.root_epoch = Epoch(i);
14185 }
14186
14187 bed.swap_to(&me);
14193 for barrier in [5usize, 9] {
14194 relay.on(Match::author(&planes[barrier - 1]).nth(1), Fault::FailFetch("flaky link".into()));
14195 }
14196
14197 let session = crate::db::current_session();
14198 let mut last = 0u64;
14199 for pass in 0..(HOPS * 3) {
14202 let held = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14203 let now = held.root_epoch.0;
14204 assert!(
14205 now >= last,
14206 "pass {pass}: epoch went BACKWARD ({last} -> {now}); a catch-up must never lose ground",
14207 );
14208 last = now;
14209 if now == HOPS {
14210 break;
14211 }
14212 let _ = follow_rekeys(&relay, &held, &session).await;
14213 }
14214
14215 let healed = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14216 assert_eq!(
14217 healed.root_epoch, Epoch(HOPS),
14218 "a deep backlog on a failing link must still converge on the head; stuck at {}",
14219 healed.root_epoch.0,
14220 );
14221 }
14222
14223 #[tokio::test(flavor = "multi_thread")]
14224 async fn a_missing_chunk_does_not_block_adopting_the_key_i_already_hold() {
14225 let (bed, owner, me) = TestBed::new();
14237 bed.swap_to(&owner);
14238 let community = create_community(&bed.relay, "PartialChunks", bed.relays.clone(), None).await.unwrap();
14239 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14240 let bundle_json = serde_json::to_string(&bundle).unwrap();
14241 bed.swap_to(&me);
14242 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14243 let _ = follow_control(&bed.relay, &joined).await;
14244 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14245
14246 bed.swap_to(&owner);
14248 let new_root = [0xC7u8; 32];
14249 let new_epoch = Epoch(joined.root_epoch.0 + 1);
14250 let prev_commit = super::super::derive::epoch_key_commitment(joined.root_epoch, &joined.community_root);
14251 let group = base_rekey_group_key(&joined.community_root, joined.id(), new_epoch);
14252 let my_blob = rekey::build_blob_local(
14253 owner.keys.secret_key(), &owner.keys.public_key().to_bytes(),
14254 &me.keys.public_key(), RekeyScope::Root, new_epoch, &new_root,
14255 ).unwrap();
14256 let rumor = rekey::build_rekey_rumor(
14258 owner.keys.public_key(), RekeyScope::Root, new_epoch, joined.root_epoch,
14259 &prev_commit, &[my_blob], 1, 2, 2_000,
14260 my_authority_citation(&joined, &owner.keys.public_key()).as_ref(), false,
14261 ).unwrap();
14262 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
14263 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14264
14265 bed.swap_to(&me);
14266 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14267 assert!(!follow.self_removed, "a missing chunk is never a removal");
14268 let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14269 assert_eq!(
14270 after.root_epoch, new_epoch,
14271 "my blob was present and opened — the missing sibling chunk says nothing about MY key, \
14272 and parking here strands the whole community behind its least reachable chunk",
14273 );
14274 }
14275
14276 #[tokio::test(flavor = "multi_thread")]
14277 async fn an_empty_rotation_is_never_a_mass_removal() {
14278 let (bed, owner, me) = TestBed::new();
14287 bed.swap_to(&owner);
14288 let community = create_community(&bed.relay, "EmptyRotation", bed.relays.clone(), None).await.unwrap();
14289 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14290 let bundle_json = serde_json::to_string(&bundle).unwrap();
14291 bed.swap_to(&me);
14292 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14293 let _ = follow_control(&bed.relay, &joined).await;
14294 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14295
14296 bed.swap_to(&owner);
14298 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[], &[0xB0; 32], &joined.community_root).await;
14299
14300 bed.swap_to(&me);
14301 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14302 assert!(
14303 !follow.self_removed,
14304 "an all-empty rotation must NEVER read as my removal — that path deletes the community",
14305 );
14306 assert!(
14307 crate::db::community::load_community_v2(joined.id()).unwrap().is_some(),
14308 "and the community must still be held",
14309 );
14310 }
14311
14312 #[tokio::test(flavor = "multi_thread")]
14313 async fn a_catch_up_interrupted_mid_walk_keeps_the_hops_it_already_took() {
14314 use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
14320 let relay = AdversarialRelay::new();
14323 let (bed, owner, me) = TestBed::new();
14324 bed.swap_to(&owner);
14325 let community = create_community(relay.relay(), "DeepCatchUp", bed.relays.clone(), None).await.unwrap();
14326
14327 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14328 let bundle_json = serde_json::to_string(&bundle).unwrap();
14329 bed.swap_to(&me);
14330 let joined = accept_parked_invite(relay.relay(), &bundle_json, None).await.unwrap();
14331 let _ = follow_control(relay.relay(), &joined).await;
14332 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14333
14334 bed.swap_to(&owner);
14336 let mut cur = community.clone();
14337 let roots = [[0xA1u8; 32], [0xA2; 32], [0xA3; 32]];
14338 let recipients = [owner.keys.public_key(), me.keys.public_key()];
14339 for (i, root) in roots.iter().enumerate() {
14340 publish_base_rotation(relay.relay(), &cur, &owner.keys, &recipients, root, &cur.community_root).await;
14341 cur.community_root = *root;
14342 cur.root_epoch = Epoch(i as u64 + 1);
14343 }
14344
14345 bed.swap_to(&me);
14347 let third_hop = base_rekey_group_key(&roots[1], joined.id(), Epoch(3));
14348 relay.on(Match::author(&third_hop.pk_hex()), Fault::FailFetch("relay dropped".into()));
14349
14350 let _ = follow_rekeys(&relay, &joined, &crate::db::current_session()).await;
14351
14352 let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14353 assert!(
14354 after.root_epoch.0 >= 2,
14355 "hops 1 and 2 succeeded and MUST be banked; an interrupted walk that reverts to epoch {} \
14356 restarts from scratch every attempt and may never converge on a flaky link",
14357 after.root_epoch.0,
14358 );
14359
14360 relay.heal();
14362 let _ = follow_rekeys(&relay, &after, &crate::db::current_session()).await;
14363 let healed = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14364 assert_eq!(healed.root_epoch, Epoch(3), "once faults cease the walk converges on the head");
14365 }
14366
14367 #[tokio::test(flavor = "multi_thread")]
14368 async fn a_promote_is_reported_as_an_authority_change() {
14369 let (bed, owner, admin) = TestBed::new();
14377 bed.swap_to(&owner);
14378 let community = create_community(&bed.relay, "PromoteReports", bed.relays.clone(), None).await.unwrap();
14379 follow_control(&bed.relay, &community).await.unwrap(); let rid = "e1".repeat(32);
14382 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14383 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
14384
14385 let control = follow_control(&bed.relay, &community).await.unwrap();
14386 assert!(
14387 control.authority_changed,
14388 "a promote MUST report an authority change — this is what re-runs the rekey walk",
14389 );
14390 assert!(control.moved(), "and therefore counts as movement");
14391 assert!(
14392 control.updated.is_none(),
14393 "while moving no document at all — which is precisely why `updated` alone was blind",
14394 );
14395 }
14396
14397 #[tokio::test(flavor = "multi_thread")]
14398 async fn a_seeded_roster_never_vetoes_a_coherent_control_fold() {
14399 let (bed, owner, admin) = TestBed::new();
14406 bed.swap_to(&owner);
14407 let community = create_community(&bed.relay, "SeedVeto", bed.relays.clone(), None).await.unwrap();
14408 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14409 let rid = "c1".repeat(32);
14410 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14411 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
14412
14413 let ghost = "d4".repeat(32);
14416 let mut seeded = crate::community::roles::CommunityRoles::default();
14417 seeded.roles.push(admin_role(&ghost, Permissions::MANAGE_CHANNELS));
14418 seeded.grants.push(crate::community::roles::MemberGrant {
14419 member: owner.keys.public_key().to_hex(),
14420 role_ids: vec![ghost.clone()],
14421 });
14422 crate::db::community::set_community_roles(&cid_hex, &seeded, 0).unwrap();
14423 crate::db::community::set_edition_head_at_epoch(&cid_hex, &ghost, 1, &[0u8; 32], &[0u8; 32], community.root_epoch.0).unwrap();
14425
14426 follow_control(&bed.relay, &community).await.unwrap().updated;
14427
14428 let folded = crate::db::community::get_community_roles(&cid_hex).unwrap();
14429 assert!(
14430 folded.grants.iter().any(|g| g.member == admin.keys.public_key().to_hex()),
14431 "a coherent fold must replace a provenance-less seed, or the client is deaf to this admin forever",
14432 );
14433 assert_ne!(
14434 crate::db::community::get_community_roles_at(&cid_hex).unwrap(),
14435 0,
14436 "and the replacement carries the plane's provenance",
14437 );
14438 }
14439
14440 #[tokio::test(flavor = "multi_thread")]
14441 async fn a_pending_severance_heals_on_a_quiet_follow_pass() {
14442 let (bed, owner, me) = TestBed::new();
14448 bed.swap_to(&owner);
14449 let community = create_community(&bed.relay, "QuietHeal", bed.relays.clone(), None).await.unwrap();
14450 let rid = "b5".repeat(32);
14451 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
14452 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
14453 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14454 let bundle_json = serde_json::to_string(&bundle).unwrap();
14455 bed.swap_to(&me);
14456 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14457 let _ = follow_control(&bed.relay, &joined).await;
14458 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14459 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
14460
14461 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
14464 add_sever_mark(&cid_hex, 1, 5_000);
14465 assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok());
14466
14467 let followed = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14469 assert!(followed.updated.is_none(), "nothing to adopt");
14470 assert!(
14471 fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err().contains("revoked"),
14472 "the interrupted severance healed with no rotation to carry it",
14473 );
14474 }
14475
14476 #[tokio::test(flavor = "multi_thread")]
14477 async fn a_sever_survives_a_later_ordinary_rotation_in_the_same_walk() {
14478 let (bed, owner, me) = TestBed::new();
14483 bed.swap_to(&owner);
14484 let community = create_community(&bed.relay, "StickySever", bed.relays.clone(), None).await.unwrap();
14485 let rid = "b6".repeat(32);
14486 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
14487 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
14488 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14489 let bundle_json = serde_json::to_string(&bundle).unwrap();
14490 bed.swap_to(&me);
14491 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14492 let _ = follow_control(&bed.relay, &joined).await;
14493 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14494 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
14495
14496 let root1 = [0xE1; 32];
14498 publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &root1, &joined.community_root).await;
14499 let mut stepped = joined.clone();
14500 stepped.community_root = root1;
14501 stepped.root_epoch = Epoch(1);
14502 let root2 = [0xE2; 32];
14503 publish_base_rotation(&bed.relay, &stepped, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &root2, &root1).await;
14504
14505 let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated.expect("both hops adopt");
14506 assert_eq!(updated.root_epoch, Epoch(2), "the walk reached the ordinary head");
14507 assert!(
14508 fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err().contains("revoked"),
14509 "the containment's burial survived the ordinary hop behind it",
14510 );
14511 }
14512
14513 async fn vended_to(bed: &TestBed, before: &CommunityV2, who: &Keys) -> bool {
14516 let next = Epoch(before.root_epoch.0 + 1);
14517 let group = base_rekey_group_key(&before.community_root, before.id(), next);
14518 let chunks = fetch_rekey_chunks(&bed.relay, &before.relays, &group).await.unwrap();
14519 rekey::collect_rotations(&chunks).iter().any(|r| {
14520 rekey::find_my_blobs(
14521 &r.blobs,
14522 &r.rotator.to_bytes(),
14523 &who.public_key().to_bytes(),
14524 RekeyScope::Root,
14525 next,
14526 )
14527 .next()
14528 .is_some()
14529 })
14530 }
14531
14532 fn local_join_observed(who: &Keys, at_ms: u64, seen: u64, epoch: u64, tag: u8) -> guestbook::GuestbookEvent {
14534 guestbook::GuestbookEvent {
14535 rumor_id: [tag; 32],
14536 entry: guestbook::GuestbookEntry::Join { member: who.public_key(), invited_by: None, at_ms },
14537 epoch: Some(epoch),
14538 observed_at: Some(seen),
14539 }
14540 }
14541
14542 #[tokio::test(flavor = "multi_thread")]
14543 async fn a_backdated_join_cannot_slide_under_the_window_when_we_watched_it_arrive() {
14544 let (bed, owner, _o) = TestBed::new();
14548 bed.swap_to(&owner);
14549 let community = create_community(&bed.relay, "Backdate", bed.relays.clone(), None).await.unwrap();
14550 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14551 let liar = Keys::generate();
14552
14553 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
14555 let join = guestbook::build_join_rumor(liar.public_key(), None, 1_000);
14556 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &liar, Timestamp::from_secs(1)).unwrap();
14557 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14558 crate::db::community::set_guestbook(&cid_hex, &[local_join_observed(&liar, 1_000, 9_000, community.root_epoch.0, 3)], 0).unwrap();
14559 crate::db::settings::set_sql_setting(format!("gb_observed_since:{cid_hex}"), "100".into()).unwrap();
14561
14562 refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
14563
14564 assert!(
14565 !vended_to(&bed, &community, &liar).await,
14566 "a backdated Join must not beat the arrival we watched with our own clock",
14567 );
14568 }
14569
14570 #[tokio::test(flavor = "multi_thread")]
14571 async fn a_client_that_started_watching_late_evicts_nobody() {
14572 let (bed, owner, _o) = TestBed::new();
14576 bed.swap_to(&owner);
14577 let community = create_community(&bed.relay, "LateWatcher", bed.relays.clone(), None).await.unwrap();
14578 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14579 let quiet = Keys::generate();
14580
14581 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
14582 let join = guestbook::build_join_rumor(quiet.public_key(), None, 1_000);
14583 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &quiet, Timestamp::from_secs(1)).unwrap();
14584 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14585 crate::db::community::set_guestbook(&cid_hex, &[local_join_observed(&quiet, 1_000, 9_000, community.root_epoch.0, 4)], 0).unwrap();
14586 crate::db::settings::set_sql_setting(format!("gb_observed_since:{cid_hex}"), "7000".into()).unwrap();
14588
14589 refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
14590
14591 assert!(
14592 vended_to(&bed, &community, &quiet).await,
14593 "without coverage predating the window our observations prove nothing — an \
14594 old member must not be evicted for having been noticed late",
14595 );
14596 }
14597
14598 #[tokio::test(flavor = "multi_thread")]
14599 async fn a_purge_vends_only_to_the_retain_list_however_late_a_stranger_arrives() {
14600 let (bed, owner, _o) = TestBed::new();
14606 bed.swap_to(&owner);
14607 let community = create_community(&bed.relay, "Purge", bed.relays.clone(), None).await.unwrap();
14608 let kept = Keys::generate();
14609 let stranger = Keys::generate();
14610 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
14611 for (who, at_ms) in [(&kept, 2_000_000u64), (&stranger, 1_000u64)] {
14613 let join = guestbook::build_join_rumor(who.public_key(), None, at_ms);
14614 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, who, Timestamp::from_secs(at_ms / 1000)).unwrap();
14615 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14616 }
14617
14618 refound_community_retaining(&bed.relay, &community, &[], &[kept.public_key()]).await.unwrap();
14620
14621 assert!(
14622 vended_to(&bed, &community, &kept).await,
14623 "the selected member keeps their key",
14624 );
14625 assert!(
14626 !vended_to(&bed, &community, &stranger).await,
14627 "an account not on the retain list is vended NO key, no matter when it \
14628 appeared or what Join time it claims",
14629 );
14630 }
14631
14632 #[tokio::test(flavor = "multi_thread")]
14633 async fn a_raid_window_joiner_is_vended_no_key_even_if_nobody_named_them() {
14634 let (bed, owner, _o) = TestBed::new();
14639 bed.swap_to(&owner);
14640 let community = create_community(&bed.relay, "RaceCut", bed.relays.clone(), None).await.unwrap();
14641 let established = Keys::generate();
14642 let late = Keys::generate();
14643 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
14644 for (who, at_ms) in [(&established, 2_000_000u64), (&late, 9_000_000)] {
14645 let join = guestbook::build_join_rumor(who.public_key(), None, at_ms);
14646 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, who, Timestamp::from_secs(at_ms / 1000)).unwrap();
14647 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14648 }
14649
14650 refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
14652
14653 assert!(
14654 !vended_to(&bed, &community, &late).await,
14655 "a raid-window joiner must be vended NO key, even when nobody named them",
14656 );
14657 assert!(
14658 vended_to(&bed, &community, &established).await,
14659 "and the member who was already here keeps theirs",
14660 );
14661 }
14662
14663 #[tokio::test(flavor = "multi_thread")]
14664 async fn a_member_whose_join_is_not_on_the_plane_is_never_read_as_a_late_joiner() {
14665 let (bed, owner, _o) = TestBed::new();
14670 bed.swap_to(&owner);
14671 let community = create_community(&bed.relay, "Buried", bed.relays.clone(), None).await.unwrap();
14672 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14673 let quiet = Keys::generate();
14674 let local_join = guestbook::GuestbookEvent {
14675 rumor_id: [7u8; 32],
14676 entry: guestbook::GuestbookEntry::Join { member: quiet.public_key(), invited_by: None, at_ms: 9_900_000 },
14677 epoch: Some(community.root_epoch.0),
14678 observed_at: None,
14679 };
14680 crate::db::community::set_guestbook(&cid_hex, &[local_join], 0).unwrap();
14681
14682 refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
14684
14685 assert!(
14686 vended_to(&bed, &community, &quiet).await,
14687 "no Join on the reachable plane means OLD — absence of evidence must never evict",
14688 );
14689 }
14690
14691 #[tokio::test(flavor = "multi_thread")]
14692 async fn contain_raid_bans_cuts_and_severs_end_to_end() {
14693 let (bed, owner, _other) = TestBed::new();
14694 bed.swap_to(&owner);
14695 let community = create_community(&bed.relay, "Containment", bed.relays.clone(), None).await.unwrap();
14696 let minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
14697
14698 let established = Keys::generate();
14701 let raider = Keys::generate();
14702 let lurker = Keys::generate();
14703 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
14704 for (who, at_ms) in [(&established, 2_000_000u64), (&raider, 9_000_000), (&lurker, 9_500_000)] {
14705 let join = guestbook::build_join_rumor(who.public_key(), None, at_ms);
14706 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, who, Timestamp::from_secs(at_ms / 1000)).unwrap();
14707 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14708 }
14709
14710 let report = contain_raid(&bed.relay, community.id(), &[raider.public_key()], 5_000, &[]).await.unwrap();
14711 assert_eq!(report.banned, 1, "the convicted raider is banned");
14712 assert_eq!(report.window_cut, 1, "the lurker who joined inside the window is cut");
14713 assert!(report.refound_ok, "the severing refound landed: {:?}", report.warnings);
14714 assert_eq!((report.epoch_before, report.epoch_after), (0, 1), "the root rolled");
14715 assert_eq!(report.own_links_revoked, 1, "the door closed with it");
14716
14717 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14718 let banlist = crate::db::community::get_community_banlist(&cid_hex).unwrap();
14719 assert!(banlist.contains(&raider.public_key().to_hex()), "raider on the banlist");
14720 assert!(!banlist.contains(&lurker.public_key().to_hex()), "the lurker is cut, never banned — they can rejoin through a fresh link");
14721 assert!(!banlist.contains(&established.public_key().to_hex()));
14722 assert!(
14723 fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err().contains("revoked"),
14724 "the invite URL the raid used is a grave",
14725 );
14726
14727 let updated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14730 assert_eq!(updated.root_epoch, Epoch(1));
14731 let members = memberlist(&bed.relay, &updated).await.unwrap();
14732 assert!(members.contains(&established.public_key()), "established member survives");
14733 assert!(!members.contains(&raider.public_key()), "raider gone");
14734 }
14735
14736 #[tokio::test(flavor = "multi_thread")]
14737 async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
14738 let (bed, owner, me) = TestBed::new();
14746 bed.swap_to(&owner);
14747 let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
14748 let rid = "b1".repeat(32);
14749 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
14751 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
14752
14753 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14754 let bundle_json = serde_json::to_string(&bundle).unwrap();
14755 bed.swap_to(&me);
14756 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14757 let _ = follow_control(&bed.relay, &joined).await;
14758 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14759
14760 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
14762 let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
14763 assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
14764
14765 let new_root = [0xD4; 32];
14768 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
14769
14770 let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
14771 .expect("the owner's Refounding is adopted");
14772 assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
14773
14774 let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
14775 assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
14776 assert_eq!(
14777 crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
14778 new_root,
14779 "and the new root behind the same URL",
14780 );
14781 }
14782
14783 #[tokio::test]
14784 async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
14785 let (bed, owner, me) = TestBed::new();
14789 let admin = Keys::generate();
14790 bed.swap_to(&owner);
14791 let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
14792 let rid = "b0".repeat(32);
14793 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14794 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
14795
14796 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14797 let bundle_json = serde_json::to_string(&bundle).unwrap();
14798 bed.swap_to(&me);
14799 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14800 let _ = follow_control(&bed.relay, &joined).await;
14801 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14802
14803 publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
14805 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14806 assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
14807 }
14808
14809 #[tokio::test]
14810 async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
14811 let (bed, owner, me) = TestBed::new();
14815 let admin_a = Keys::generate();
14816 let admin_b = Keys::generate(); bed.swap_to(&owner);
14818 let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
14819 let rid = "b0".repeat(32);
14820 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14821 publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
14823 publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
14824
14825 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14826 let bundle_json = serde_json::to_string(&bundle).unwrap();
14827 bed.swap_to(&me);
14828 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14829 let _ = follow_control(&bed.relay, &joined).await;
14830 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14831
14832 publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
14834
14835 let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14836 assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
14837 }
14838
14839 #[tokio::test]
14840 async fn a_retried_refounding_reuses_the_same_root() {
14841 let (_tmp, _guard, _owner) = init_test_db();
14844 let relay = MemoryRelay::new();
14845 let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
14846 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14847 let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
14848 let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
14849 assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
14850 }
14851
14852 #[tokio::test]
14853 async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
14854 let (bed, owner, attacker) = TestBed::new();
14861 bed.swap_to(&owner);
14862 let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
14863
14864 let senior = "a1".repeat(32);
14866 let mid = "a5".repeat(32);
14867 publish_role(&bed.relay, &community, &owner.keys,
14868 &Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0, extra: Default::default() }, 1).await;
14869 publish_role(&bed.relay, &community, &owner.keys,
14870 &Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0, extra: Default::default() }, 1).await;
14871 publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
14872
14873 publish_role(&bed.relay, &community, &attacker.keys,
14875 &Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0, extra: Default::default() }, 2).await;
14876
14877 let authority = fetch_authority(&bed.relay, &community).await;
14878 let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
14879 assert_eq!(
14880 folded_senior.position, 1,
14881 "a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
14882 );
14883 }
14884
14885 #[tokio::test]
14886 async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
14887 let (bed, owner, admin) = TestBed::new();
14894 bed.swap_to(&owner);
14895 let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
14896 let rid = "c1".repeat(32);
14897 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
14898 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
14899
14900 let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
14902 edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
14903 let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
14904 .filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
14905 .max_by_key(|e| e.version).expect("the owner's metadata edition");
14906 assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
14907
14908 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14911 let bundle_json = serde_json::to_string(&bundle).unwrap();
14912 bed.swap_to(&admin);
14913 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14914 let _ = follow_control(&bed.relay, &joined).await;
14915 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14916 set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
14917
14918 let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
14919 .find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
14920 .expect("the admin's banlist edition");
14921 let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
14922 assert_eq!(
14923 cite.entity_id,
14924 crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
14925 "the citation must name the ACTOR'S OWN grant coordinate",
14926 );
14927 assert!(cite.version >= 1, "pinned to a real grant version");
14928 }
14929
14930 #[tokio::test]
14931 async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
14932 let (_tmp, _guard, _owner) = init_test_db();
14938 let relay = MemoryRelay::new();
14939 let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
14940
14941 let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
14942 let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
14943 edit_community_metadata(&relay, &community, &meta).await.unwrap();
14944
14945 let updated = follow_control(&relay, &community).await.unwrap().updated
14946 .expect("the metadata edition is folded");
14947 assert_eq!(
14948 updated.relays.len(),
14949 crate::community::MAX_COMMUNITY_RELAYS,
14950 "a folded relay list must be truncated, never adopted whole",
14951 );
14952
14953 let again = follow_control(&relay, &updated).await.unwrap().updated;
14957 assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
14958 }
14959
14960 #[tokio::test]
14961 async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
14962 let (bed, owner, me) = TestBed::new();
14968 bed.swap_to(&owner);
14969 let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
14970 let rid = "b2".repeat(32);
14971 publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14972 publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
14973
14974 let bundle = bundle_of(&host, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14975 let bundle_json = serde_json::to_string(&bundle).unwrap();
14976 bed.swap_to(&me);
14977 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14978 let _ = follow_control(&bed.relay, &joined).await;
14979 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14980
14981 let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
14984 mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
14985
14986 let before = bed.relay.stored_count();
14987 let new_root = [0xE1; 32];
14988 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
14989 let rotation_events = bed.relay.stored_count() - before;
14990
14991 let after_adopt = bed.relay.stored_count();
14992 follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14993 assert_eq!(
14994 bed.relay.stored_count(),
14995 after_adopt,
14996 "adopting a rotation must publish NOTHING when I minted no links here",
14997 );
14998 assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
14999 }
15000
15001 #[tokio::test]
15002 async fn an_expired_link_stops_keeping_the_community_public() {
15003 let (_tmp, _guard, _owner) = init_test_db();
15009 let relay = MemoryRelay::new();
15010 let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
15011
15012 let past = now_ms() - 60_000;
15014 mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
15015 assert!(
15016 !community_is_public(&relay, &community).await,
15017 "an already-expired link must never read as a live door",
15018 );
15019
15020 mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
15022 assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
15023 }
15024
15025 #[tokio::test]
15026 async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
15027 let (_tmp, _guard, _owner) = init_test_db();
15030 let relay = MemoryRelay::new();
15031 let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
15032 assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
15033
15034 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
15035 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
15036 let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
15037 assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
15038
15039 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
15040 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
15041 assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
15042 let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
15043 assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
15044 }
15045
15046 #[tokio::test]
15047 async fn the_registry_is_cached_locally_so_public_private_is_a_sync_read() {
15048 let (_tmp, _guard, _owner) = init_test_db();
15052 let relay = MemoryRelay::new();
15053 let community = create_community(&relay, "Cached", vec!["wss://r".into()], None).await.unwrap();
15054 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15055 let cached = || crate::db::community::get_community_invite_registry(&cid_hex).unwrap();
15056 let per_creator = || crate::db::community::get_invite_link_sets(&cid_hex).unwrap();
15059 assert!(cached().is_empty(), "a fresh community caches an empty registry");
15060 assert!(per_creator().is_empty(), "…and no per-creator sets");
15061
15062 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
15063 assert!(!cached().is_empty(), "minting caches the registry, so the UI reads Public without folding");
15064 let sets = per_creator();
15065 assert_eq!(sets.len(), 1, "the minting creator gets a set");
15066 assert_eq!(sets[0].locators.len(), 1, "carrying exactly their one live link");
15067
15068 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
15070 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
15071 assert!(cached().is_empty(), "retiring the last link empties the cache back to Private");
15072 assert!(per_creator().is_empty(), "…and clears the per-creator sets");
15073 }
15074
15075 #[tokio::test]
15076 async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
15077 let (_tmp, _guard, owner) = init_test_db();
15084 let relay = MemoryRelay::new();
15085 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
15086 mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
15087 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
15088
15089 let cid = community.id();
15090 let control = control::ControlPlane::of(&community).write_group().unwrap();
15091 let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
15092
15093 let query = Query {
15094 kinds: vec![stream::KIND_WRAP],
15095 authors: vec![control.pk_hex()],
15096 limit: Some(FOLLOW_PAGE),
15097 ..Default::default()
15098 };
15099 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
15100 let target = wraps
15101 .iter()
15102 .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
15103 .filter(|e| e.entity_id == eid)
15104 .max_by_key(|e| e.version)
15105 .expect("the owner published a registry");
15106
15107 let rogue = Keys::generate();
15110 let mut planted = false;
15111 for n in 0..4_000u64 {
15112 let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
15113 let rumor = control::build_edition_rumor(
15114 rogue.public_key(),
15115 vsk::INVITE_LINKS,
15116 &eid,
15117 target.version,
15118 target.prev_hash.as_ref(),
15119 &content,
15120 9_000,
15121 None,
15122 );
15123 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
15124 let (ed, _) = control::open_control_edition(&w, &control).unwrap();
15125 if ed.inner_id < target.inner_id {
15126 relay.publish(&w, &community.relays).await.unwrap();
15127 planted = true;
15128 break;
15129 }
15130 }
15131 assert!(planted, "the test needs a fork that wins the tiebreak");
15132
15133 assert!(
15134 community_is_public(&relay, &community).await,
15135 "an unauthorised fork must not retire the owner's live link"
15136 );
15137 }
15138
15139 #[tokio::test]
15140 async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
15141 let (_tmp, _guard, owner) = init_test_db();
15143 let relay = MemoryRelay::new();
15144 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
15145 let rogue = Keys::generate();
15146 let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
15148 let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
15149 let group = control::ControlPlane::of(&community).write_group().unwrap();
15150 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
15151 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
15152 relay.publish(&wrap, &community.relays).await.unwrap();
15153 let _ = owner;
15154 assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
15155 }
15156
15157 #[tokio::test]
15158 async fn full_lifecycle_e2e() {
15159 let (bed, owner, member) = TestBed::new();
15165
15166 bed.swap_to(&owner);
15167 let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
15168 let general = community.channels[0].id;
15169 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
15170
15171 let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
15173 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
15174
15175 let rid = "aa".repeat(32);
15177 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
15178 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
15179
15180 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
15182 let bundle_json = serde_json::to_string(&bundle).unwrap();
15183 bed.swap_to(&member);
15184 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15185 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
15186 let _ = follow_control(&bed.relay, &joined).await;
15189 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
15190 assert!(joined.control_root.is_some(), "the Grant's control_wrap was adopted on fold");
15191 publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
15192
15193 bed.swap_to(&owner);
15195 let updated = follow_control(&bed.relay, &community).await.unwrap().updated.expect("the admin edit folds");
15196 assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
15197
15198 set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
15200 grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
15201 let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
15202 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
15203 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
15205 assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
15206 assert!(
15208 all_texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
15209 "pre-refounding history stays reachable by paging"
15210 );
15211
15212 bed.swap_to(&member);
15215 let session = crate::db::current_session();
15216 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
15217 assert!(follow.self_removed, "the banned member is cryptographically cut");
15218
15219 bed.swap_to(&owner);
15221 dissolve_community(&bed.relay, &refounded).await.unwrap();
15222 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
15223 }
15224
15225 #[tokio::test]
15231 async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
15232 let (bed, owner, member) = TestBed::new();
15237 let attacker = Keys::generate();
15238 bed.swap_to(&owner);
15239 let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
15240 let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
15241 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
15242 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
15243 follow_control(&bed.relay, &community).await.unwrap().updated;
15245 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
15246
15247 publish_role(&bed.relay, &community, &attacker, &Role { role_id: rid.clone(), name: "pwn".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0, extra: Default::default() }, 2).await;
15249
15250 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
15252 assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
15253
15254 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
15256 assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
15257 }
15258
15259 #[tokio::test]
15260 async fn memberlist_survives_a_refounding_via_the_snapshot() {
15261 let (bed, owner, member) = TestBed::new();
15264 bed.swap_to(&owner);
15265 let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
15266
15267 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
15269 bed.swap_to(&member);
15270 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
15271 bed.swap_to(&owner);
15272 assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
15273
15274 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
15276 assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
15277
15278 let members = memberlist(&bed.relay, &refounded).await.unwrap();
15280 assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
15281 assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
15282 }
15283
15284 #[tokio::test]
15285 async fn e2e_two_accounts_channels_converse_moderate() {
15286 use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
15287 use nostr_sdk::prelude::ToBech32;
15288 let (bed, a, b) = TestBed::new();
15289 let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
15290 let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
15291 println!("\n===== Concord v2 deep e2e =====");
15292 println!("[acct] A (owner) = {a_npub}");
15293 println!("[acct] B (member) = {b_npub}");
15294
15295 bed.swap_to(&a);
15297 let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
15298 let general = community.channels[0].id;
15299 println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
15300
15301 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
15305 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15306 let priv_ch = community.channel(&priv_id).unwrap();
15307 assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
15308 println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
15309
15310 let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
15312 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15313 let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
15314 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15315 println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
15316 assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
15317
15318 let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
15320 send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
15321 send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
15322 println!("[msg] A posted in #general / #announcements / #mods");
15323
15324 let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
15326 publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
15327 publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
15328 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
15329 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
15330 println!("[invite] granted B @admin · minted link {}", link.url);
15331
15332 grant_channel_access(&bed.relay, &community, &priv_id, &b.keys.public_key()).await.unwrap();
15335 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(b.keys.public_key()), Some(a.keys.public_key()), None, None)).unwrap();
15336 bed.swap_to(&b);
15337 let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15338 println!("[join] B joined; sees {} channels", b_view.channels.len());
15339 assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
15340 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");
15341 assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
15342 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");
15343 if let Some(fresh) = follow_control(&bed.relay, &b_view).await.unwrap().updated {
15346 b_view = fresh;
15347 }
15348 println!("[follow] B folded control (roster persisted: B is @admin)");
15349
15350 send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
15352 send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
15353 println!("[msg] B replied in #general + #mods");
15354 let my_pk = b.keys.public_key();
15357 let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
15358 for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
15359 let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
15360 if let Some(o) = outcome { persist_chat(&gh, &o).await; }
15361 }
15362 assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
15363 println!("[persist] #general history persisted into the shared events store");
15364
15365 send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
15367 bed.swap_to(&a);
15368 let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
15369 send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!", &[]).await.unwrap();
15370 let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
15371 send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
15372 println!("[ops] reaction + edit + delete round-tripped");
15373
15374 bed.swap_to(&b);
15376 let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
15377 println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
15378 bed.swap_to(&a);
15379 if let Some(updated) = follow_control(&bed.relay, &community).await.unwrap().updated {
15380 community = updated;
15381 }
15382 assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
15383 println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
15384
15385 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
15391 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15392 send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
15393 println!("[channel] +private #vault {} (B is unentitled — no delivery)", crate::simd::hex::bytes_to_hex_32(&vault.0));
15394 bed.swap_to(&b);
15395 let session_b2 = crate::db::current_session();
15396 if let Some(fresh) = follow_control(&bed.relay, &b_view).await.unwrap().updated {
15397 b_view = fresh;
15398 }
15399 let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
15400 assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
15401 let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
15402 if let Some(fresh) = rf.updated {
15403 b_view = fresh;
15404 }
15405 let ch = b_view.channel(&vault).expect("still recorded");
15406 assert!(ch.key.is_none(), "an unentitled member is never delivered the key");
15407 assert!(
15408 texts_in(&bed.relay, &b_view, &vault).await.is_empty(),
15409 "and reads nothing from it"
15410 );
15411 assert!(
15412 send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.is_err(),
15413 "an unentitled member cannot post into the channel either"
15414 );
15415 bed.swap_to(&a);
15416 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15417 println!("[private] #vault stayed sealed to the unentitled B (no key, no read, no send)");
15418
15419 let members = memberlist(&bed.relay, &community).await.unwrap();
15421 let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
15422 assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
15423 println!("[members] {} members: A + B present", members.len());
15424
15425 set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
15427 grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
15428 let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
15429 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
15430 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
15431 assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
15432 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");
15433 assert!(
15434 all_texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
15435 "PRIVATE history pages back across the channel's own rotation (per-channel multi-epoch archive)"
15436 );
15437 println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
15438 bed.swap_to(&b);
15440 let session_b3 = crate::db::current_session();
15441 assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
15442 println!("[ban] B's rekey-follow: self_removed = true (severed)");
15443
15444 bed.swap_to(&a);
15446 set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
15447 let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
15448 assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
15449 println!("[unban] B removed from the banlist (re-invitable)");
15450
15451 dissolve_community(&bed.relay, &refounded).await.unwrap();
15453 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
15454 println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
15455 }
15456
15457 #[tokio::test]
15463 #[ignore]
15464 async fn live_e2e_two_accounts() {
15465 use crate::community::transport::LiveTransport;
15466 use nostr_sdk::prelude::ToBech32;
15467
15468 let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
15469 let relays = vec![relay.clone()];
15470 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
15471 crate::db::close_database();
15472 crate::db::clear_id_caches();
15473 let tmp = tempfile::tempdir().unwrap();
15474 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
15475
15476 let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
15478 let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
15479
15480 let log = |line: String| {
15481 println!("{line}");
15482 if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
15483 use std::io::Write;
15484 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
15485 let _ = writeln!(f, "{line}");
15486 }
15487 }
15488 };
15489 log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
15490 log(format!("VECTOR_E2E_NSEC_A={} ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
15491 log(format!("VECTOR_E2E_NSEC_B={} ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
15492
15493 for k in [&a, &b] {
15494 let npub = k.public_key().to_bech32().unwrap();
15495 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
15496 crate::db::set_current_account(npub.clone()).unwrap();
15497 crate::db::init_database(&npub).unwrap();
15498 }
15499 let client = crate::nostr_client_builder().build();
15502 client.add_managed_relay(relay.as_str()).await.ok();
15503 client.connect().await;
15504 crate::state::set_nostr_client(client);
15505 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
15506 let become_acct = |k: &Keys| {
15507 let npub = k.public_key().to_bech32().unwrap();
15508 crate::db::set_current_account(npub.clone()).unwrap();
15509 crate::db::init_database(&npub).unwrap();
15510 crate::db::clear_id_caches();
15511 crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
15512 crate::state::set_my_public_key(k.public_key());
15513 };
15514 let settle = || crate::rt::time::sleep(std::time::Duration::from_secs(2));
15515
15516 become_acct(&a);
15518 let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
15519 let general = community.channels[0].id;
15520 log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
15521 send_message(&transport, &community, &general, "A: live hello").await.expect("send");
15522 let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
15523 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15524 log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
15525 grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
15526 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
15527 log(format!("[invite] B granted @admin · link {}", link.url));
15528 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(a.public_key()), None, None)).unwrap();
15529 settle().await;
15530
15531 become_acct(&b);
15533 let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
15534 log(format!("[join] B joined; {} channels", b_view.channels.len()));
15535 settle().await;
15536 let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
15537 let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
15538 log(format!("[read] B sees #general: {seen:?}"));
15539 assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
15540 send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
15541
15542 let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
15546 let hello_id = hello.event.opened().rumor_id.to_hex();
15547 let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
15548 let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
15549 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());
15550 let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
15551 transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
15552 log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
15553 settle().await;
15554
15555 become_acct(&a);
15557 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15558 let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
15559 let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
15560 if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
15561 assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
15562 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");
15563 } else {
15564 panic!("the 1111 parsed as a Message");
15565 }
15566 log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
15567 become_acct(&b);
15568 settle().await;
15569
15570 become_acct(&a);
15574 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15575 let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
15576 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15577 send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
15578 log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
15579 settle().await;
15580
15581 become_acct(&b);
15582 let session_b = crate::db::current_session();
15583 let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
15584 if let Some(fresh) = follow_control(&transport, &b_view).await.expect("B control follow").updated {
15585 b_view = fresh;
15586 }
15587 if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
15588 b_view = fresh;
15589 }
15590 let vch = b_view.channel(&vault).expect("B folded the vault");
15591 assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
15592 let vseen = texts_in(&transport, &b_view, &vault).await;
15593 log(format!("[read] B sees #vault: {vseen:?}"));
15594 assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
15595 send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
15596 settle().await;
15597
15598 become_acct(&a);
15599 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15600 assert!(
15601 texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
15602 "A reads B's private reply"
15603 );
15604 log("[private] two-way #vault conversation over the live relay".to_string());
15605
15606 set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
15608 grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
15609 let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
15610 log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
15611 settle().await;
15612 dissolve_community(&transport, &refounded).await.expect("dissolve");
15613 log("[dissolve] community sealed".to_string());
15614 log("===== LIVE e2e PASS =====".to_string());
15615 }
15616
15617 #[tokio::test]
15618 async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
15619 let (bed, owner, member) = TestBed::new();
15623 bed.swap_to(&owner);
15624 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
15625 let general = community.channels[0].id;
15626 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
15627
15628 bed.swap_to(&member);
15629 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
15630 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
15631
15632 bed.swap_to(&owner);
15634 dissolve_community(&bed.relay, &community).await.unwrap();
15635
15636 bed.swap_to(&member);
15638 let session = crate::db::current_session();
15639 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
15640 assert!(follow.dissolved, "the catch-up surfaces the tombstone");
15641 assert!(!follow.self_removed && follow.updated.is_none());
15642 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
15643 assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
15644 let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
15645 assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
15646 let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
15648 assert!(again.dissolved && again.updated.is_none());
15649 }
15650
15651 #[tokio::test]
15652 async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
15653 const PRIV_CHANNELS: usize = 6;
15658 let (bed, owner, member) = TestBed::new();
15659 bed.swap_to(&owner);
15660 let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
15661 let mut priv_ids = Vec::new();
15662 for i in 0..PRIV_CHANNELS {
15663 let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
15664 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15665 send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
15666 priv_ids.push(id);
15667 }
15668 for id in &priv_ids {
15670 grant_channel_access(&bed.relay, &community, id, &member.keys.public_key()).await.unwrap();
15671 }
15672 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
15673
15674 bed.swap_to(&member);
15676 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15677 assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
15678
15679 bed.swap_to(&owner);
15681 for epoch in 1..=2u64 {
15682 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
15683 assert_eq!(community.root_epoch, Epoch(epoch));
15684 for id in &priv_ids {
15685 send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
15686 }
15687 }
15688
15689 bed.swap_to(&member);
15691 let session = crate::db::current_session();
15692 let mut passes = 0;
15693 loop {
15694 passes += 1;
15695 assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
15696 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15697 let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
15698 assert!(!rk.self_removed);
15699 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15700 let ctl = follow_control(&bed.relay, &cur).await.unwrap().updated;
15701 if rk.updated.is_none() && ctl.is_none() {
15702 break;
15703 }
15704 }
15705 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15706 assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
15707 for id in &priv_ids {
15709 let mine = caught_up.channel(id).expect("channel survived");
15710 let theirs = community.channel(id).unwrap();
15711 assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
15712 assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
15713 let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
15714 let open = texts_in(&bed.relay, &caught_up, id).await;
15718 assert!(open.iter().any(|t| t.contains("epoch2")), "channel {id_hex} opens onto its live epoch");
15719 let paged = all_texts_in(&bed.relay, &caught_up, id).await;
15720 assert!(paged.iter().any(|t| t.contains("epoch0")), "channel {id_hex} pages back to epoch-0 history");
15721 for epoch in 1..=2u64 {
15722 assert!(paged.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} pages back to epoch-{epoch} history");
15723 }
15724 }
15725 }
15726
15727 #[tokio::test]
15728 async fn a_backlog_deeper_than_max_steps_still_converges() {
15729 let (bed, owner, member) = TestBed::new();
15734 bed.swap_to(&owner);
15735 let mut community = create_community(&bed.relay, "DeepSleeper", bed.relays.clone(), None).await.unwrap();
15736 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
15737
15738 bed.swap_to(&member);
15739 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15740 assert_eq!(joined.root_epoch, Epoch(0));
15741
15742 bed.swap_to(&owner);
15744 let depth = MAX_WALK_STEPS + 3;
15745 for _ in 0..depth {
15746 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
15747 }
15748 assert_eq!(community.root_epoch, Epoch(depth as u64));
15749
15750 bed.swap_to(&member);
15753 let mut last = 0u64;
15754 for pass in 1..=8 {
15755 let view = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15758 let session = crate::db::current_session();
15759 let _ = follow_rekeys(&bed.relay, &view, &session).await.unwrap();
15760 let view = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15761 assert!(
15762 view.root_epoch.0 >= last,
15763 "pass {pass} lost ground: {} -> {}",
15764 last, view.root_epoch.0
15765 );
15766 last = view.root_epoch.0;
15767 if last == depth as u64 {
15768 break;
15769 }
15770 }
15771 let view = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15772 assert_eq!(
15773 view.root_epoch, Epoch(depth as u64),
15774 "a backlog deeper than one walk's step bound must still converge across passes",
15775 );
15776 }
15777
15778 #[tokio::test]
15779 async fn an_offline_member_catches_up_across_three_refoundings() {
15780 use nostr_sdk::prelude::ToBech32;
15785 let (bed, owner, member) = TestBed::new();
15786 bed.swap_to(&owner);
15787 let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
15788 let general = community.channels[0].id;
15789 let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
15790 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15791 send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
15792 send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
15793 grant_channel_access(&bed.relay, &community, &mods, &member.keys.public_key()).await.unwrap();
15795 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
15796
15797 bed.swap_to(&member);
15799 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15800 assert_eq!(member_view.root_epoch, Epoch(0));
15801
15802 bed.swap_to(&owner);
15804 let stranger = Keys::generate();
15805 for epoch in 1..=3u64 {
15806 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
15807 assert_eq!(community.root_epoch, Epoch(epoch));
15808 send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
15809 send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
15810 }
15811 let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
15812 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15813 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
15814 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15815 grant_channel_access(&bed.relay, &community, &vault, &member.keys.public_key()).await.unwrap();
15818 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15819 send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
15820 set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
15821 let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
15822 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
15823
15824 bed.swap_to(&member);
15827 let session = crate::db::current_session();
15828 let mut passes = 0;
15829 loop {
15830 passes += 1;
15831 assert!(passes <= 6, "catch-up must converge, not churn");
15832 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15833 let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
15834 assert!(!rekeyed.self_removed, "the member was never removed");
15835 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15836 let controlled = follow_control(&bed.relay, &cur).await.unwrap().updated;
15837 if rekeyed.updated.is_none() && controlled.is_none() {
15838 break;
15839 }
15840 }
15841 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15842
15843 assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
15845 assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
15846 assert_eq!(caught_up.name, "Sleeper Reborn");
15847 assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
15849 let m = caught_up.channel(&mods).expect("mods survived");
15850 let owner_mods = community.channel(&mods).unwrap();
15851 assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
15852 assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
15853 let v = caught_up.channel(&vault).expect("vault folded in");
15854 assert!(v.private && v.key.is_none(), "vault folds in keyless: entitled, but never delivered");
15859 let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
15861 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
15862 assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
15863 let gen_open = texts_in(&bed.relay, &caught_up, &general).await;
15868 assert!(gen_open.contains(&"epoch3: general news".to_string()), "general opens onto its live epoch: {gen_open:?}");
15869 let gen_texts = all_texts_in(&bed.relay, &caught_up, &general).await;
15870 for epoch in 0..=3u64 {
15871 let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
15872 assert!(gen_texts.contains(&needle), "general pages back to epoch {epoch}: {gen_texts:?}");
15873 }
15874 let mods_open = texts_in(&bed.relay, &caught_up, &mods).await;
15875 assert!(mods_open.contains(&"epoch3: mods word".to_string()), "mods opens onto its live epoch: {mods_open:?}");
15876 let mods_texts = all_texts_in(&bed.relay, &caught_up, &mods).await;
15877 for epoch in 0..=3u64 {
15878 let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
15879 assert!(mods_texts.contains(&needle), "private pages back to epoch {epoch}: {mods_texts:?}");
15880 }
15881 assert!(texts_in(&bed.relay, &caught_up, &vault).await.is_empty());
15884 send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
15886 bed.swap_to(&owner);
15887 assert!(
15888 texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
15889 "the caught-up member converses at the new epoch ({})",
15890 member.keys.public_key().to_bech32().unwrap()
15891 );
15892 }
15893
15894 async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
15897 let general = community.channels[0].id;
15898 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
15899 for i in 0..n {
15900 let at = base_secs + i as u64;
15901 let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
15902 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
15903 relay.publish(&wrap, &community.relays).await.unwrap();
15904 }
15905 }
15906
15907 #[tokio::test]
15908 async fn the_history_walk_pages_past_a_multi_page_burst() {
15909 let (_tmp, _guard, owner) = init_test_db();
15912 let relay = MemoryRelay::new();
15913 let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
15914 let general = community.channels[0].id;
15915 flood_general(&relay, &community, &owner, 120, 10_000).await;
15916
15917 let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
15918 assert_eq!(all.len(), 120, "the walk pages the whole burst");
15919 let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
15921 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
15922 assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
15923 let unique: std::collections::HashSet<&String> = contents.iter().collect();
15924 assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
15925
15926 let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
15928 assert_eq!(one.len(), 50, "fetch_channel is one newest page");
15929 assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
15930 }
15931
15932 #[tokio::test]
15933 async fn a_start_until_cursor_pages_history_from_that_point_backwards() {
15934 let (_tmp, _guard, owner) = init_test_db();
15938 let relay = MemoryRelay::new();
15939 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
15940 let general = community.channels[0].id;
15941 flood_general(&relay, &community, &owner, 120, 10_000).await;
15942
15943 let older = fetch_channel_history(
15944 &relay, &community, &general, 50, 8, None, Some(10_059),
15945 crate::community::transport::Evidence::Quorum, |_| true,
15946 )
15947 .await
15948 .unwrap();
15949 let contents: Vec<String> = older.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
15950 assert_eq!(contents.len(), 60, "everything at-or-before the cursor, nothing after");
15951 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
15952 assert_eq!(contents.last().map(String::as_str), Some("msg 59"));
15953 }
15954
15955 #[tokio::test]
15956 async fn the_history_walk_stops_when_the_caller_is_caught_up() {
15957 let (_tmp, _guard, owner) = init_test_db();
15958 let relay = MemoryRelay::new();
15959 let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
15960 let general = community.channels[0].id;
15961 flood_general(&relay, &community, &owner, 120, 10_000).await;
15962
15963 let mut pages = 0usize;
15965 let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| {
15966 pages += 1;
15967 false
15968 })
15969 .await
15970 .unwrap();
15971 assert_eq!(pages, 1, "the early stop is consulted once");
15972 assert_eq!(got.len(), 50, "only the newest page is fetched");
15973 assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
15974 }
15975
15976 #[tokio::test]
15977 async fn a_same_second_history_wall_terminates_instead_of_looping() {
15978 let (_tmp, _guard, owner) = init_test_db();
15982 let relay = MemoryRelay::new();
15983 let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
15984 let general = community.channels[0].id;
15985 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
15986 for i in 0..60usize {
15987 let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
15988 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
15989 relay.publish(&wrap, &community.relays).await.unwrap();
15990 }
15991 let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
15992 assert!(got.len() >= 25, "at least the relay page is read");
15993 assert!(got.len() <= 60, "sane bound");
15994 }
15996
15997 #[tokio::test]
15998 async fn a_grant_revoke_survives_a_withholding_relay() {
15999 let (_tmp, _guard, owner) = init_test_db();
16002 let relay = MemoryRelay::new();
16003 let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
16004 let admin = Keys::generate();
16005 let rid = "d4".repeat(32);
16006 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
16007 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
16008 follow_control(&relay, &community).await.unwrap().updated; publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; follow_control(&relay, &community).await.unwrap().updated;
16011
16012 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
16014 let mut stale = community.clone();
16015 stale.relays = vec!["wss://stale".into()];
16016 let floors = load_floors(&community);
16017 let editions = fetch_control(&relay, &stale).await;
16018 let authority = fold_authority(&stale, &editions, &floors);
16019 assert!(
16020 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
16021 "the persisted grant floor refuses the rolled-back (re-granted) view"
16022 );
16023 }
16024
16025 fn load_floors(community: &CommunityV2) -> Floors {
16027 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16028 crate::db::community::get_all_edition_heads_full(&cid_hex)
16029 .unwrap_or_default()
16030 .into_iter()
16031 .filter(|(_, f)| f.0 == community.root_epoch.0)
16032 .map(|(e, f)| (e, (f.1, f.2, f.3)))
16033 .collect()
16034 }
16035
16036 async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
16038 let group = control::ControlPlane::of(&community).write_group().unwrap();
16039 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
16040 relay
16041 .fetch(&q, &community.relays)
16042 .await
16043 .unwrap_or_default()
16044 .iter()
16045 .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
16046 .collect()
16047 }
16048
16049 #[tokio::test]
16050 async fn follow_control_is_a_noop_on_a_freshly_created_community() {
16051 let (_tmp, _guard, _owner) = init_test_db();
16052 let relay = MemoryRelay::new();
16053 let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
16054 assert!(follow_control(&relay, &community).await.unwrap().updated.is_none());
16056 }
16057
16058 #[tokio::test]
16059 async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
16060 let (_tmp, _guard, owner) = init_test_db();
16061 let relay = MemoryRelay::new();
16062 let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
16063 let new_id = ChannelId([0x5a; 32]);
16064 publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
16065
16066 let updated = follow_control(&relay, &community).await.unwrap().updated.expect("a new channel changed the view");
16067 assert_eq!(updated.channels.len(), 2);
16068 let added = updated.channel(&new_id).expect("the new channel folded in");
16069 assert_eq!(added.name, "announcements");
16070 assert!(!added.private);
16071 assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
16072
16073 let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
16075 let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
16076 assert!(authors.contains(&addr), "the added channel joins the live subscription");
16077
16078 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16080 assert!(reloaded.channel(&new_id).is_some());
16081 }
16082
16083 #[tokio::test]
16084 async fn follow_control_renames_the_community_and_an_existing_channel() {
16085 let (_tmp, _guard, owner) = init_test_db();
16086 let relay = MemoryRelay::new();
16087 let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
16088 let general = community.channels[0].id;
16089 publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
16091 publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
16092
16093 let updated = follow_control(&relay, &community).await.unwrap().updated.unwrap();
16094 assert_eq!(updated.name, "New Name");
16095 assert_eq!(updated.channel(&general).unwrap().name, "lobby");
16096 assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
16097 }
16098
16099 #[tokio::test]
16100 async fn follow_control_deletes_a_channel() {
16101 let (_tmp, _guard, owner) = init_test_db();
16102 let relay = MemoryRelay::new();
16103 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
16104 let extra = ChannelId([0x77; 32]);
16105
16106 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
16108 let with_extra = follow_control(&relay, &community).await.unwrap().updated.expect("added");
16109 assert!(with_extra.channel(&extra).is_some());
16110
16111 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
16113 let updated = follow_control(&relay, &with_extra).await.unwrap().updated.expect("removed");
16114 assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
16115 assert_eq!(updated.channels.len(), 1, "only #general remains");
16116 }
16117
16118 async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
16122 let group = control::ControlPlane::of(&community).write_group().unwrap();
16123 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
16124 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
16125 for w in &wraps {
16126 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
16127 if ed.version <= max_version {
16128 relay.inject(w, &[stale_relay.to_string()]);
16129 }
16130 }
16131 }
16132 }
16133
16134 #[tokio::test]
16135 async fn a_withholding_relay_cannot_roll_back_a_rename() {
16136 let (_tmp, _guard, owner) = init_test_db();
16139 let relay = MemoryRelay::new();
16140 let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
16141 publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
16142
16143 let updated = follow_control(&relay, &community).await.unwrap().updated.expect("rename adopted");
16144 assert_eq!(updated.name, "Renamed");
16145
16146 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
16148 let mut stale_view = updated.clone();
16149 stale_view.relays = vec!["wss://stale".into()];
16150 assert!(
16151 follow_control(&relay, &stale_view).await.unwrap().updated.is_none(),
16152 "a stale-only relay must not change the held view"
16153 );
16154 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16155 assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
16156 }
16157
16158 #[tokio::test]
16159 async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
16160 let (_tmp, _guard, owner) = init_test_db();
16161 let relay = MemoryRelay::new();
16162 let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
16163 let extra = ChannelId([0x44; 32]);
16164
16165 publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
16168 assert!(follow_control(&relay, &community).await.unwrap().updated.is_none());
16169
16170 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
16171 let with_extra = follow_control(&relay, &community).await.unwrap().updated.expect("added");
16172 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
16173 let pruned = follow_control(&relay, &with_extra).await.unwrap().updated.expect("removed");
16174 assert!(pruned.channel(&extra).is_none());
16175
16176 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
16178 let mut stale_view = pruned.clone();
16179 stale_view.relays = vec!["wss://stale".into()];
16180 assert!(
16181 follow_control(&relay, &stale_view).await.unwrap().updated.is_none(),
16182 "the withheld delete must not resurrect the channel"
16183 );
16184 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16185 assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
16186 }
16187
16188 #[tokio::test]
16189 async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
16190 let (_tmp, _guard, owner) = init_test_db();
16194 let relay = MemoryRelay::new();
16195 let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
16196 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
16197 let updated = follow_control(&relay, &community).await.unwrap().updated.expect("edit adopted");
16198 assert_eq!(updated.name, "Edited");
16199
16200 let refounded = rotate_view(&updated, updated.community_root, 1);
16203 crate::db::community::save_community_v2(&refounded).unwrap();
16204 publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
16205
16206 let adopted = follow_control(&relay, &refounded).await.unwrap().updated.expect("compacted head adopted");
16207 assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
16208 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16210 let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
16211 assert!(
16212 heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
16213 "the adopted head carries the fold's epoch + version"
16214 );
16215 }
16216
16217 #[tokio::test]
16218 async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
16219 let (_tmp, _guard, owner) = init_test_db();
16224 let relay = MemoryRelay::new();
16225 let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
16226 let group = control::ControlPlane::of(&community).write_group().unwrap();
16227 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
16228
16229 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
16230 let ours = follow_control(&relay, &community).await.unwrap().updated.expect("ours adopted");
16231 assert_eq!(ours.name, "Ours");
16232
16233 let our_inner = {
16235 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
16236 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
16237 wraps
16238 .iter()
16239 .find_map(|w| {
16240 control::open_control_edition(w, &group)
16241 .ok()
16242 .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
16243 .map(|(ed, _)| ed.inner_id)
16244 })
16245 .unwrap()
16246 };
16247
16248 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
16251 let content = serde_json::to_string(&meta).unwrap();
16252 let mut ts = 2_000u64;
16253 let fork_wrap = loop {
16254 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
16255 let inner = rumor.id.unwrap().to_bytes();
16256 if inner < our_inner {
16257 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
16258 }
16259 ts += 1;
16260 };
16261 relay.publish(&fork_wrap, &community.relays).await.unwrap();
16262
16263 let converged = follow_control(&relay, &ours).await.unwrap().updated.expect("fork winner adopted");
16264 assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
16265 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16266 let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
16267 assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
16268 }
16269
16270 #[tokio::test]
16271 async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
16272 let (_tmp, _guard, owner) = init_test_db();
16276 let relay = MemoryRelay::new();
16277 let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
16278 let group = control::ControlPlane::of(&community).write_group().unwrap();
16279
16280 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
16281 let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
16282
16283 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
16285 let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
16286 let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
16287 let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
16288 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
16289 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
16290 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
16291 relay.publish(&w4, &community.relays).await.unwrap();
16292
16293 let updated = follow_control(&relay, &community).await.unwrap().updated.expect("the verified prefix applies");
16294 assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
16295
16296 relay.publish(&w3, &community.relays).await.unwrap();
16297 let healed = follow_control(&relay, &updated).await.unwrap().updated.expect("the chain heals");
16298 assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
16299 }
16300
16301 #[tokio::test]
16302 async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
16303 let (_tmp, _guard, owner) = init_test_db();
16307 let relay = MemoryRelay::new();
16308 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
16309 let group = control::ControlPlane::of(&community).write_group().unwrap();
16310
16311 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
16312 let base = follow_control(&relay, &community).await.unwrap().updated.expect("floor at v2");
16313 publish_community_meta(&relay, &base, &owner, "Three", 3).await; let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
16315
16316 let rogue = Keys::generate();
16319 for i in 0..(FOLLOW_PAGE as u64 - 1) {
16320 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
16321 let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
16322 relay.publish(&w, &community.relays).await.unwrap();
16323 }
16324 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
16327 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
16328 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
16329 relay.publish(&w4, &community.relays).await.unwrap();
16330
16331 let healed = follow_control(&relay, &base).await.unwrap().updated.expect("paging recovered the chain");
16332 assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
16333 }
16334
16335 #[tokio::test]
16336 async fn a_follow_after_delete_does_not_resurrect_the_community() {
16337 let (_tmp, _guard, owner) = init_test_db();
16340 let relay = MemoryRelay::new();
16341 let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
16342 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
16343 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16344 crate::db::community::delete_community(&cid_hex).unwrap();
16345
16346 assert!(
16347 follow_control(&relay, &community).await.unwrap().updated.is_none(),
16348 "a follow racing a delete is a no-op"
16349 );
16350 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
16351 assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
16352 }
16353
16354 #[tokio::test]
16355 async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
16356 let (_tmp, _guard, owner) = init_test_db();
16359 let relay = MemoryRelay::new();
16360 let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
16361 let new_root = [0xB2; 32];
16362 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
16363 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16364 crate::db::community::delete_community(&cid_hex).unwrap();
16365
16366 let session = crate::db::current_session();
16367 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
16368 assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
16369 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
16370 }
16371
16372 #[tokio::test]
16373 async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
16374 let (bed, owner, member) = TestBed::new();
16379 bed.swap_to(&owner);
16380 let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
16381 let group = control::ControlPlane::of(&community).write_group().unwrap();
16382 let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
16383
16384 let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
16386 let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
16387 let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
16388 let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
16389 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
16390 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);
16391 let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
16392 bed.relay.publish(&w3, &community.relays).await.unwrap();
16393
16394 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
16395 let bundle_json = serde_json::to_string(&bundle).unwrap();
16396 bed.swap_to(&member);
16397 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
16398 assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
16399 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
16400 let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
16401 assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
16402 }
16403
16404 #[tokio::test]
16405 async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
16406 let (_tmp, _guard, owner) = init_test_db();
16410 let relay = MemoryRelay::new();
16411 let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
16412 let group = control::ControlPlane::of(&community).write_group().unwrap();
16413 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
16414
16415 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
16416 let ours = follow_control(&relay, &community).await.unwrap().updated.expect("ours adopted");
16417 assert_eq!(ours.name, "Ours");
16418 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16419 let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
16420 let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
16421
16422 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
16425 let content = serde_json::to_string(&meta).unwrap();
16426 let mut ts = 5_000u64;
16427 let fork_wrap = loop {
16428 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
16429 if rumor.id.unwrap().to_bytes() > our_inner {
16430 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
16431 }
16432 ts += 1;
16433 };
16434 inject_stale_prefix(&relay, &community, 1, "wss://stale").await; relay.inject(&fork_wrap, &["wss://stale".to_string()]);
16436 let mut stale_view = ours.clone();
16437 stale_view.relays = vec!["wss://stale".into()];
16438
16439 assert!(
16440 follow_control(&relay, &stale_view).await.unwrap().updated.is_none(),
16441 "a losing fork served without our floor edition changes nothing"
16442 );
16443 let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
16444 assert_eq!(held_after, held_before, "the floor row is untouched");
16445 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16446 assert_eq!(held.name, "Ours", "the held state is untouched");
16447 }
16448
16449 #[tokio::test]
16450 async fn follow_control_ignores_a_non_owner_edition() {
16451 let (_tmp, _guard, _owner) = init_test_db();
16455 let relay = MemoryRelay::new();
16456 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
16457 let rogue = Keys::generate();
16458 let rogue_id = ChannelId([0x99; 32]);
16459 publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
16460
16461 assert!(
16462 follow_control(&relay, &community).await.unwrap().updated.is_none(),
16463 "a non-owner control edition is not folded"
16464 );
16465 }
16466
16467 #[tokio::test]
16468 async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
16469 let (_tmp, _guard, owner) = init_test_db();
16473 let relay = MemoryRelay::new();
16474 let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
16475 let priv_id = ChannelId([0x33; 32]);
16476 publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
16477
16478 let updated = follow_control(&relay, &community)
16479 .await
16480 .unwrap()
16481 .updated
16482 .expect("the keyless record is a change");
16483 let ch = updated.channel(&priv_id).expect("the private channel is recorded");
16484 assert!(ch.private && ch.key.is_none(), "recorded keyless");
16485 assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
16486 assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
16487 assert!(
16488 fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
16489 "a keyless fetch returns empty (and never queries the root plane)"
16490 );
16491 assert!(
16492 send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
16493 "a keyless send refuses"
16494 );
16495 let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
16498 let rch = reloaded.channel(&priv_id).unwrap();
16499 assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
16500 let bundle = bundle_of(&reloaded, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
16504 assert!(
16505 !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
16506 "an ungrantable keyless channel stays out of invite bundles"
16507 );
16508 }
16509
16510 #[tokio::test]
16511 async fn a_link_bundle_never_carries_a_private_channel_key() {
16512 let (_tmp, _guard, _owner) = init_test_db();
16517 let relay = MemoryRelay::new();
16518 let community = create_community(&relay, "Leak", vec!["wss://r".into()], None).await.unwrap();
16519 create_private_channel(&relay, &community, "mods").await.unwrap();
16520 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16521 let priv_hex = held
16522 .channels
16523 .iter()
16524 .find(|c| c.private)
16525 .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
16526 .expect("the private channel is held WITH its key");
16527
16528 let link = bundle_of(&held, BundleAudience::Link, None, None, None);
16529 assert!(
16530 !link.channels.iter().any(|c| c.id == priv_hex),
16531 "a held private key must never ride a link bundle"
16532 );
16533 assert!(
16534 link.channels.iter().any(|c| c.id != priv_hex),
16535 "the public channels still ride it"
16536 );
16537
16538 let stranger = bundle_of(&held, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
16542 assert!(
16543 !stranger.channels.iter().any(|c| c.id == priv_hex),
16544 "an unentitled member gets no private key"
16545 );
16546 let mine = bundle_of(&held, BundleAudience::Member(me_pk().unwrap()), None, None, None);
16547 assert!(
16548 mine.channels.iter().any(|c| c.id == priv_hex),
16549 "the creator is entitled via the companion access role"
16550 );
16551 }
16552
16553 async fn seat_ranked_role(
16557 relay: &MemoryRelay,
16558 community: &CommunityV2,
16559 member: &PublicKey,
16560 name: &str,
16561 position: u32,
16562 permissions: crate::community::roles::Permissions,
16563 ) -> String {
16564 let role_id = crate::crypto::sha256_hex(format!("test/role/{name}/{position}").as_bytes());
16565 let role = crate::community::roles::Role {
16566 role_id: role_id.clone(),
16567 name: name.to_string(),
16568 position,
16569 permissions,
16570 scope: crate::community::roles::RoleScope::Server,
16571 color: 0, extra: Default::default(),
16572 };
16573 set_role(relay, community, &role).await.unwrap();
16574 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16575 let mut ids: Vec<String> = {
16576 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16577 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16578 roster.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect()
16579 };
16580 if !ids.contains(&role_id) {
16581 ids.push(role_id.clone());
16582 }
16583 grant_roles(relay, &held, member, ids).await.unwrap();
16584 role_id
16585 }
16586
16587 fn act_as(who: &Keys) {
16590 crate::state::MY_SECRET_KEY.store_from_keys(who, &[]);
16591 crate::state::set_my_public_key(who.public_key());
16592 }
16593
16594 #[tokio::test]
16595 async fn a_moderator_cannot_revoke_channel_access_from_a_superior_and_the_channel_never_rotates() {
16596 let (_tmp, _guard, owner) = init_test_db();
16602 let relay = MemoryRelay::new();
16603 let community = create_community(&relay, "Ranked", vec!["wss://r".into()], None).await.unwrap();
16604 let vault = create_private_channel(&relay, &community, "vault").await.unwrap();
16605
16606 let superior = Keys::generate();
16607 let moderator = Keys::generate();
16608 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16609 grant_admin(&relay, &held, &superior.public_key()).await.unwrap();
16610 seat_ranked_role(
16611 &relay,
16612 &community,
16613 &moderator.public_key(),
16614 "Moderator",
16615 5,
16616 crate::community::roles::Permissions(crate::community::roles::Permissions::MANAGE_ROLES),
16617 )
16618 .await;
16619
16620 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16622 grant_channel_access(&relay, &held, &vault, &superior.public_key()).await.unwrap();
16623 let before = crate::db::community::load_community_v2(community.id())
16624 .unwrap()
16625 .unwrap()
16626 .channel(&vault)
16627 .unwrap()
16628 .epoch;
16629
16630 act_as(&moderator);
16631 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16632 let err = revoke_channel_access(&relay, &held, &vault, &superior.public_key())
16633 .await
16634 .expect_err("a moderator must not act on an admin");
16635 assert!(err.contains("outrank"), "refused for RANK, not some incidental reason: {err}");
16636
16637 let after = crate::db::community::load_community_v2(community.id())
16638 .unwrap()
16639 .unwrap()
16640 .channel(&vault)
16641 .unwrap()
16642 .epoch;
16643 assert_eq!(after, before, "the refusal must also stop the rotation — the rekey IS the severance");
16644 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16645 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16646 let chan_hex = crate::simd::hex::bytes_to_hex_32(&vault.0);
16647 let owner_hex = owner.public_key().to_hex();
16648 assert!(
16649 roster.is_entitled(Some(&owner_hex), &superior.public_key().to_hex(), &chan_hex, &[], &[]),
16650 "and the superior keeps their access role"
16651 );
16652 }
16653
16654 #[tokio::test]
16655 async fn a_moderator_cannot_grant_channel_access_to_a_superior_but_the_owner_stays_grantable() {
16656 let (_tmp, _guard, owner) = init_test_db();
16660 let relay = MemoryRelay::new();
16661 let community = create_community(&relay, "Ranked", vec!["wss://r".into()], None).await.unwrap();
16662 let vault = create_private_channel(&relay, &community, "vault").await.unwrap();
16663
16664 let superior = Keys::generate();
16665 let moderator = Keys::generate();
16666 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16667 grant_admin(&relay, &held, &superior.public_key()).await.unwrap();
16668 let chan_hex = crate::simd::hex::bytes_to_hex_32(&vault.0);
16669 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16672 grant_channel_access(&relay, &held, &vault, &moderator.public_key()).await.unwrap();
16673 seat_ranked_role(
16674 &relay,
16675 &community,
16676 &moderator.public_key(),
16677 "Moderator",
16678 5,
16679 crate::community::roles::Permissions(crate::community::roles::Permissions::MANAGE_ROLES),
16680 )
16681 .await;
16682
16683 act_as(&moderator);
16684 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16685 let err = grant_channel_access(&relay, &held, &vault, &superior.public_key())
16686 .await
16687 .expect_err("a moderator must not grant against an admin");
16688 assert!(err.contains("outrank"), "refused for RANK: {err}");
16689
16690 grant_channel_access(&relay, &held, &vault, &owner.public_key())
16692 .await
16693 .expect("the owner is not a rank target, and may still be handed a key");
16694 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16695 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16696 let owner_hex = owner.public_key().to_hex();
16697 assert!(
16698 roster.is_entitled(Some(&owner_hex), &owner_hex, &chan_hex, &[], &[]),
16699 "the owner's access role landed"
16700 );
16701 }
16702
16703 #[tokio::test]
16704 async fn a_private_channel_mints_its_access_role_and_entitlement_follows_the_grant() {
16705 let (_tmp, _guard, _owner) = init_test_db();
16708 let relay = MemoryRelay::new();
16709 let community = create_community(&relay, "Scoped", vec!["wss://r".into()], None).await.unwrap();
16710 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
16711 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16712 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16713
16714 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16715 let access = roster.channel_roles(&chan_hex);
16716 assert_eq!(access.len(), 1, "the channel minted exactly one access role");
16717 assert!(
16718 access[0].permissions == crate::community::roles::Permissions::empty(),
16719 "the access role confers READ access (key possession), never authority"
16720 );
16721 assert_eq!(access[0].name, "mods", "named for its channel");
16722
16723 let stranger = Keys::generate().public_key();
16725 let owner_hex = community.owner().unwrap().to_hex();
16726 assert!(!roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]));
16727
16728 let role_id = access[0].role_id.clone();
16731 assert!(
16732 roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, std::slice::from_ref(&role_id), &[]),
16733 "the grant overlay entitles before the fold catches up"
16734 );
16735
16736 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16737 grant_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
16738 let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
16739 assert!(
16740 after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
16741 "the grant landed in the local roster (the fold runs later)"
16742 );
16743 let vend = bundle_of(&held, BundleAudience::Member(stranger), None, None, None);
16744 assert!(
16745 vend.channels.iter().any(|c| c.id == chan_hex),
16746 "a now-entitled member's bundle carries the channel key"
16747 );
16748
16749 revoke_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
16750 let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
16751 assert!(
16752 !after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
16753 "the revoke dropped the access role"
16754 );
16755 let rotated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16756 assert_eq!(
16757 rotated.channel(&priv_id).unwrap().epoch,
16758 Epoch(2),
16759 "the revoke rotated the channel — a removal that doesn't rekey severs nobody"
16760 );
16761
16762 let access = crate::VectorCore.channel_access(&cid_hex, &chan_hex).unwrap();
16764 assert_eq!(access["private"], true);
16765 assert_eq!(access["readable"], true, "we minted it, so we hold its key");
16766 assert_eq!(access["roles"].as_array().unwrap().len(), 1, "one access role");
16767 let holders = access["members"].as_array().unwrap();
16768 let me_npub = {
16769 use nostr_sdk::prelude::ToBech32;
16770 me_pk().unwrap().to_bech32().unwrap()
16771 };
16772 assert_eq!(holders.len(), 1, "only the creator holds it — the revoked member is gone");
16773 assert_eq!(holders[0], serde_json::json!(me_npub), "and that holder is the creator");
16774 }
16775
16776 #[tokio::test]
16777 async fn a_public_ban_severs_the_private_channels_the_member_could_read() {
16778 let (_tmp, _guard, _owner) = init_test_db();
16783 let relay = MemoryRelay::new();
16784 let community = create_community(&relay, "Sever", vec!["wss://r".into()], None).await.unwrap();
16785 let mods = create_private_channel(&relay, &community, "mods").await.unwrap();
16786 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16787 let vault = create_private_channel(&relay, &held, "vault").await.unwrap();
16788 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16789
16790 let spammer = Keys::generate().public_key();
16791 let bystander = Keys::generate().public_key();
16792 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16793 grant_channel_access(&relay, &held, &mods, &spammer).await.unwrap();
16794 grant_channel_access(&relay, &held, &mods, &bystander).await.unwrap();
16795
16796 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16799 let stripped: Vec<String> = roster.roles_of(&spammer.to_hex()).map(|r| r.role_id.clone()).collect();
16800 assert!(!stripped.is_empty(), "the grant landed before the strip");
16801 grant_roles(&relay, &held, &spammer, vec![]).await.unwrap();
16802
16803 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16804 let rotated = sever_banned_private_reads(&relay, &held, &[(spammer, stripped)]).await.unwrap();
16805 assert_eq!(rotated, 1, "exactly the one channel they could read rotated");
16806 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16807 assert_eq!(after.channel(&mods).unwrap().epoch, Epoch(2), "the reachable channel advanced");
16808 assert_eq!(after.channel(&vault).unwrap().epoch, Epoch(1), "the unreachable channel did not");
16809
16810 let stranger = Keys::generate().public_key();
16812 let n = sever_banned_private_reads(&relay, &after, &[(stranger, Vec::new())]).await.unwrap();
16813 assert_eq!(n, 0, "no entitlement, no rotation");
16814 }
16815
16816 #[tokio::test]
16817 async fn ban_severance_refuses_without_manage_channels() {
16818 let (bed, owner, moderator) = TestBed::new();
16822 bed.swap_to(&owner);
16823 let community = create_community(&bed.relay, "Gated", bed.relays.clone(), None).await.unwrap();
16824 create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16825 let rid = "c1".repeat(32);
16826 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
16827 publish_grant(&bed.relay, &community, &owner.keys, &moderator.keys.public_key(), vec![rid], 1).await;
16828 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16829 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
16830 let bundle_json = serde_json::to_string(&bundle).unwrap();
16831
16832 bed.swap_to(&moderator);
16833 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
16834 let _ = follow_control(&bed.relay, &joined).await;
16835 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16836 let target = Keys::generate().public_key();
16837 let err = sever_banned_private_reads(&bed.relay, &joined, &[(target, Vec::new())]).await.unwrap_err();
16838 assert!(err.contains("permission"), "refused at the gate, not mid-rotation: {err}");
16839 }
16840
16841 #[tokio::test]
16842 async fn set_banlist_refuses_an_author_the_fold_would_reject() {
16843 let (bed, owner, member) = TestBed::new();
16847 bed.swap_to(&owner);
16848 let community = create_community(&bed.relay, "Soap", bed.relays.clone(), None).await.unwrap();
16849 let rid = "c2".repeat(32);
16850 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
16851 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16852 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
16853 let bundle_json = serde_json::to_string(&bundle).unwrap();
16854
16855 bed.swap_to(&member);
16857 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
16858 let _ = follow_control(&bed.relay, &joined).await;
16859 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16860 let target = Keys::generate().public_key().to_hex();
16861 let err = set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap_err();
16862 assert!(err.contains("BAN"), "refused for the missing bit: {err}");
16863 assert!(
16864 crate::db::community::get_community_banlist(&crate::simd::hex::bytes_to_hex_32(&joined.id().0)).unwrap().is_empty(),
16865 "no phantom echo cached"
16866 );
16867
16868 bed.swap_to(&owner);
16871 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
16872 let peer = Keys::generate().public_key();
16873 publish_grant(&bed.relay, &community, &owner.keys, &peer, vec![rid], 2).await;
16874 bed.swap_to(&member);
16875 let _ = follow_control(&bed.relay, &joined).await;
16876 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16877 let err = set_banlist(&bed.relay, &joined, &[peer.to_hex()]).await.unwrap_err();
16878 assert!(err.contains("outrank"), "an equal is not actionable: {err}");
16879 set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap();
16880 }
16881
16882 #[tokio::test]
16883 async fn metadata_and_link_minting_refuse_unauthorized_authors() {
16884 let (bed, owner, member) = TestBed::new();
16887 bed.swap_to(&owner);
16888 let community = create_community(&bed.relay, "Locked", bed.relays.clone(), None).await.unwrap();
16889 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16890 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
16891 let bundle_json = serde_json::to_string(&bundle).unwrap();
16892
16893 bed.swap_to(&member);
16894 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
16895 let _ = follow_control(&bed.relay, &joined).await;
16896 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16897 let meta = control::CommunityMetadata {
16898 name: "Hijacked".into(),
16899 description: None,
16900 relays: vec![],
16901 icon: None,
16902 banner: None,
16903 custom: None,
16904 extra: Default::default(),
16905 };
16906 assert!(edit_community_metadata(&bed.relay, &joined, &meta).await.is_err(), "metadata edit refused");
16907 assert!(
16908 mint_public_link(&bed.relay, &joined, "https://vectorapp.io/i/", None, None).await.is_err(),
16909 "link minting refused"
16910 );
16911 }
16912
16913 #[tokio::test]
16914 async fn a_vended_key_parks_until_the_fold_proves_the_grant_then_adopts() {
16915 let (bed, owner, member) = TestBed::new();
16919 bed.swap_to(&owner);
16920 let community = create_community(&bed.relay, "Vend", bed.relays.clone(), None).await.unwrap();
16921 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16922 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16923 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16924 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16925 let real_key = held.channel(&priv_id).unwrap().key.unwrap();
16926 let owner_hex = community.owner().unwrap().to_hex();
16927
16928 bed.swap_to(&member);
16931 let me = member.keys.public_key().to_hex();
16932 let mut member_view = held.clone();
16935 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16936 c.key = None;
16937 c.epoch = Epoch(0);
16938 }
16939
16940 let empty = crate::community::roles::CommunityRoles::default();
16943 assert!(matches!(
16944 judge_channel_key_vend(&member_view, &empty, &priv_id, Epoch(1), &owner_hex),
16945 VendVerdict::Park(_)
16946 ));
16947
16948 let mut public_view = member_view.clone();
16950 if let Some(c) = public_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16951 c.private = false;
16952 }
16953 assert!(matches!(
16954 judge_channel_key_vend(&public_view, &empty, &priv_id, Epoch(1), &owner_hex),
16955 VendVerdict::Refuse(_)
16956 ));
16957
16958 assert!(matches!(
16960 judge_channel_key_vend(&member_view, &empty, &ChannelId([0x77; 32]), Epoch(1), &owner_hex),
16961 VendVerdict::Park(_)
16962 ));
16963
16964 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
16967 crate::db::community::set_community_roles(&cid_hex, &empty, 0).unwrap();
16968 crate::db::community::save_community_v2(&member_view).unwrap();
16969 let session = crate::db::current_session();
16970 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16971 assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "unprovable vend adopts nothing");
16972 assert_eq!(
16973 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
16974 1,
16975 "and stays parked for the next fold"
16976 );
16977
16978 let access = crate::community::roles::Role {
16980 role_id: "44".repeat(32),
16981 name: "mods".into(),
16982 position: u32::MAX - 1,
16983 permissions: crate::community::roles::Permissions::empty(),
16984 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
16985 color: 0, extra: Default::default(),
16986 };
16987 let folded = crate::community::roles::CommunityRoles {
16988 grants: vec![crate::community::roles::MemberGrant { member: me.clone(), role_ids: vec![access.role_id.clone()] }],
16989 roles: vec![access],
16990 };
16991 crate::db::community::set_community_roles(&cid_hex, &folded, 1).unwrap();
16992 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16993 let adopted = absorb_parked_channel_keys(&reloaded, &session);
16994 assert_eq!(adopted.len(), 1, "the re-judge adopts once the grant folds");
16995
16996 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16997 let ch = after.channel(&priv_id).unwrap();
16998 assert_eq!(ch.key, Some(real_key), "adopted the vended key");
16999 assert_eq!(ch.epoch, Epoch(1));
17000 assert!(
17001 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
17002 "and the park is discharged"
17003 );
17004 }
17005
17006 #[tokio::test]
17007 async fn a_vend_from_an_unentitled_sender_is_never_seated_but_survives_for_the_real_one() {
17008 let (bed, owner, member) = TestBed::new();
17014 bed.swap_to(&owner);
17015 let community = create_community(&bed.relay, "Vendors", bed.relays.clone(), None).await.unwrap();
17016 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
17017 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17018 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
17019 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17020 let owner_hex = community.owner().unwrap().to_hex();
17021
17022 bed.swap_to(&member);
17023 let mut member_view = held.clone();
17024 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
17025 c.key = None;
17026 c.epoch = Epoch(0);
17027 }
17028 crate::db::community::save_community_v2(&member_view).unwrap();
17029
17030 let access = crate::community::roles::Role {
17033 role_id: "66".repeat(32),
17034 name: "mods".into(),
17035 position: u32::MAX - 1,
17036 permissions: crate::community::roles::Permissions::empty(),
17037 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
17038 color: 0, extra: Default::default(),
17039 };
17040 let roster = crate::community::roles::CommunityRoles {
17041 grants: vec![crate::community::roles::MemberGrant {
17042 member: member.keys.public_key().to_hex(),
17043 role_ids: vec![access.role_id.clone()],
17044 }],
17045 roles: vec![access],
17046 };
17047 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
17048
17049 let stranger_hex = Keys::generate().public_key().to_hex();
17050 let junk = [0x11; 32];
17051 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &junk, &stranger_hex).unwrap();
17052
17053 let session = crate::db::current_session();
17054 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17055 assert!(
17056 absorb_parked_channel_keys(&reloaded, &session).is_empty(),
17057 "an unentitled vendor seats nothing"
17058 );
17059 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17060 assert_eq!(after.channel(&priv_id).unwrap().key, None, "the channel is still keyless");
17061 assert_eq!(
17062 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
17063 1,
17064 "parked, not refused — the vendor's own entitlement may simply not have folded here yet"
17065 );
17066
17067 let real = [0x5a; 32];
17069 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &real, &owner_hex).unwrap();
17070 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17071 assert_eq!(
17072 absorb_parked_channel_keys(&reloaded, &session).len(),
17073 1,
17074 "the genuine vend is seated — a stranger cannot occupy the slot"
17075 );
17076 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17077 assert_eq!(after.channel(&priv_id).unwrap().key, Some(real), "and it is the ENTITLED vendor's key");
17078 assert!(
17079 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
17080 "both candidates discharge once one is seated"
17081 );
17082 }
17083
17084 #[tokio::test]
17085 async fn a_vend_at_epoch_zero_is_adopted_onto_a_keyless_channel() {
17086 let (bed, owner, member) = TestBed::new();
17091 bed.swap_to(&owner);
17092 let community = create_community(&bed.relay, "EpochZero", bed.relays.clone(), None).await.unwrap();
17093 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
17094 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17095 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
17096 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17097 let vended = [0x5a; 32];
17098 let owner_hex = community.owner().unwrap().to_hex();
17099
17100 bed.swap_to(&member);
17101 let mut member_view = held.clone();
17103 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
17104 c.key = None;
17105 c.epoch = Epoch(0);
17106 }
17107 crate::db::community::save_community_v2(&member_view).unwrap();
17108
17109 let access = crate::community::roles::Role {
17111 role_id: "77".repeat(32),
17112 name: "mods".into(),
17113 position: u32::MAX - 1,
17114 permissions: crate::community::roles::Permissions::empty(),
17115 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
17116 color: 0, extra: Default::default(),
17117 };
17118 let roster = crate::community::roles::CommunityRoles {
17119 grants: vec![crate::community::roles::MemberGrant {
17120 member: member.keys.public_key().to_hex(),
17121 role_ids: vec![access.role_id.clone()],
17122 }],
17123 roles: vec![access],
17124 };
17125 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
17126 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &vended, &owner_hex).unwrap();
17127
17128 let session = crate::db::current_session();
17129 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17130 let adopted = absorb_parked_channel_keys(&reloaded, &session);
17131 assert_eq!(adopted.len(), 1, "an epoch-0 vend onto a keyless channel is adopted");
17132
17133 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17134 let ch = after.channel(&priv_id).unwrap();
17135 assert_eq!(ch.key, Some(vended), "the key actually landed on the row");
17136 assert_eq!(ch.epoch, Epoch(0), "at the epoch the vendor named");
17137 assert!(
17138 !after.channel_read_coords(ch).is_empty(),
17139 "and the channel is readable — the whole point"
17140 );
17141 assert!(
17142 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
17143 "the park is discharged"
17144 );
17145 }
17146
17147 #[tokio::test]
17148 async fn a_wildly_ahead_vend_epoch_is_refused_not_seated() {
17149 let (bed, owner, member) = TestBed::new();
17155 bed.swap_to(&owner);
17156 let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
17157 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
17158 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17159 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
17160 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17161 let owner_hex = community.owner().unwrap().to_hex();
17162
17163 bed.swap_to(&member);
17164 let mut member_view = held.clone();
17165 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
17166 c.key = None;
17167 c.epoch = Epoch(0);
17168 }
17169 crate::db::community::save_community_v2(&member_view).unwrap();
17170 let access = crate::community::roles::Role {
17171 role_id: "99".repeat(32),
17172 name: "mods".into(),
17173 position: u32::MAX - 1,
17174 permissions: crate::community::roles::Permissions::empty(),
17175 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
17176 color: 0, extra: Default::default(),
17177 };
17178 let roster = crate::community::roles::CommunityRoles {
17179 grants: vec![crate::community::roles::MemberGrant {
17180 member: member.keys.public_key().to_hex(),
17181 role_ids: vec![access.role_id.clone()],
17182 }],
17183 roles: vec![access],
17184 };
17185 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
17186 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17187
17188 assert!(matches!(
17190 judge_channel_key_vend(&reloaded, &roster, &priv_id, Epoch(1 << 40), &owner_hex),
17191 VendVerdict::Refuse(_)
17192 ));
17193 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1 << 40, &[0xEE; 32], &owner_hex).unwrap();
17195 let session = crate::db::current_session();
17196 assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "a poison epoch adopts nothing");
17197 assert!(
17198 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
17199 "and the row is discharged rather than parked forever"
17200 );
17201 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17203 assert_eq!(after.channel(&priv_id).unwrap().epoch, Epoch(0), "head never advanced");
17204 assert!(matches!(
17205 judge_channel_key_vend(&after, &roster, &priv_id, Epoch(1), &owner_hex),
17206 VendVerdict::Accept
17207 ));
17208 }
17209
17210 #[tokio::test]
17211 async fn a_channel_rename_lands_locally_without_waiting_for_the_fold() {
17212 let (_tmp, _guard, _owner) = init_test_db();
17215 let relay = MemoryRelay::new();
17216 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
17217 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
17218 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17219 let key_before = held.channel(&priv_id).unwrap().key;
17220
17221 let mut meta = held.channel(&priv_id).unwrap().metadata();
17222 meta.name = "staff".into();
17223 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
17224
17225 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17226 let ch = after.channel(&priv_id).unwrap();
17227 assert_eq!(ch.name, "staff", "the rename is visible immediately");
17228 assert!(ch.private, "and privacy survives the edit");
17229 assert_eq!(ch.key, key_before, "as does the key — a rename is not a rotation");
17230 }
17231
17232 #[tokio::test]
17233 async fn a_channel_rename_carries_its_companion_access_role() {
17234 let (_tmp, _guard, _owner) = init_test_db();
17235 let relay = MemoryRelay::new();
17236 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
17237 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
17238 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17239 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
17240
17241 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
17242 let before = roster.channel_roles(&chan_hex);
17243 assert_eq!(before.len(), 1, "one companion role, minted at create");
17244 assert_eq!(before[0].name, "mods", "named after the channel it gates");
17245 let role_id = before[0].role_id.clone();
17246
17247 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17248 let mut meta = held.channel(&priv_id).unwrap().metadata();
17249 meta.name = "staff".into();
17250 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
17251
17252 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
17253 let after = roster.channel_roles(&chan_hex);
17254 assert_eq!(after.len(), 1, "renamed in place, never duplicated");
17255 assert_eq!(after[0].role_id, role_id, "a rename is a versioned edit of the same id");
17256 assert_eq!(after[0].name, "staff", "the access role followed the channel");
17257 assert_eq!(
17258 after[0].permissions,
17259 crate::community::roles::Permissions::empty(),
17260 "and still confers read access, never authority"
17261 );
17262 }
17263
17264 #[tokio::test]
17265 async fn a_customised_access_role_name_survives_a_channel_rename() {
17266 let (_tmp, _guard, _owner) = init_test_db();
17269 let relay = MemoryRelay::new();
17270 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
17271 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
17272 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17273 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
17274
17275 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
17276 let mut role = roster.channel_roles(&chan_hex)[0].clone();
17277 role.name = "Lab Insiders".into();
17278 set_role(&relay, &community, &role).await.unwrap();
17279 merge_local_roster(&cid_hex, Some(&role), None);
17280
17281 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17282 let mut meta = held.channel(&priv_id).unwrap().metadata();
17283 meta.name = "staff".into();
17284 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
17285
17286 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
17287 assert_eq!(
17288 roster.channel_roles(&chan_hex)[0].name,
17289 "Lab Insiders",
17290 "a deliberate name is left alone"
17291 );
17292 }
17293
17294 #[tokio::test]
17295 async fn a_squatted_park_row_cannot_suppress_the_genuine_vend() {
17296 let (bed, owner, member) = TestBed::new();
17302 bed.swap_to(&owner);
17303 let community = create_community(&bed.relay, "Squat", bed.relays.clone(), None).await.unwrap();
17304 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
17305 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17306 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
17307 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17308 let real_key = held.channel(&priv_id).unwrap().key.unwrap();
17309 let owner_hex = community.owner().unwrap().to_hex();
17310
17311 bed.swap_to(&member);
17312 let mut member_view = held.clone();
17313 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
17314 c.key = None;
17315 c.epoch = Epoch(0);
17316 }
17317 crate::db::community::save_community_v2(&member_view).unwrap();
17318 let access = crate::community::roles::Role {
17319 role_id: "aa".repeat(32),
17320 name: "mods".into(),
17321 position: u32::MAX - 1,
17322 permissions: crate::community::roles::Permissions::empty(),
17323 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
17324 color: 0, extra: Default::default(),
17325 };
17326 let roster = crate::community::roles::CommunityRoles {
17327 grants: vec![crate::community::roles::MemberGrant {
17328 member: member.keys.public_key().to_hex(),
17329 role_ids: vec![access.role_id.clone()],
17330 }],
17331 roles: vec![access],
17332 };
17333 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
17334
17335 let stranger = Keys::generate().public_key().to_hex();
17337 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 9, &[0xBA; 32], &stranger).unwrap();
17338 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
17340 assert_eq!(
17341 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
17342 2,
17343 "the squatter never displaces the genuine vend — both are candidates"
17344 );
17345
17346 let session = crate::db::current_session();
17347 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17348 let adopted = absorb_parked_channel_keys(&reloaded, &session);
17349 assert_eq!(adopted.len(), 1, "exactly one adoption");
17350
17351 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17352 let ch = after.channel(&priv_id).unwrap();
17353 assert_eq!(ch.key, Some(real_key), "the OWNER's key won, not the squatter's");
17354 assert_eq!(ch.epoch, Epoch(1), "at the genuine epoch");
17355 assert!(
17356 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
17357 "and every candidate for the channel is discharged"
17358 );
17359 }
17360
17361 #[tokio::test]
17362 async fn revoking_without_a_folded_access_role_refuses_instead_of_evicting_everyone() {
17363 let (_tmp, _guard, _owner) = init_test_db();
17368 let relay = MemoryRelay::new();
17369 let community = create_community(&relay, "NoRole", vec!["wss://r".into()], None).await.unwrap();
17370 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
17371 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17372 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17373 let before = held.channel(&priv_id).unwrap().epoch;
17374
17375 crate::db::community::set_community_roles(&cid_hex, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
17379 let mut blind = held.clone();
17380 blind.relays = vec!["wss://empty".into()];
17381 let err = revoke_channel_access(&relay, &blind, &priv_id, &Keys::generate().public_key())
17382 .await
17383 .unwrap_err();
17384 assert!(err.contains("has not folded"), "refuses with a retryable reason: {err}");
17385
17386 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17387 assert_eq!(after.channel(&priv_id).unwrap().epoch, before, "and rotates nothing");
17388 }
17389
17390 async fn publish_base_rotation(
17397 relay: &MemoryRelay,
17398 community: &CommunityV2,
17399 rotator: &Keys,
17400 recipients: &[PublicKey],
17401 new_root: &[u8; 32],
17402 prev_key: &[u8; 32],
17403 ) {
17404 let new_epoch = Epoch(community.root_epoch.0 + 1);
17405 let prev_epoch = community.root_epoch;
17406 let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
17407 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
17408 let blobs: Vec<_> = recipients
17409 .iter()
17410 .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
17411 .collect();
17412 let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000, my_authority_citation(community, &rotator.public_key()).as_ref(), false).unwrap();
17413 for e in &events {
17414 relay.publish(e, &community.relays).await.unwrap();
17415 }
17416 }
17417
17418 async fn publish_severing_base_rotation(
17420 relay: &MemoryRelay,
17421 community: &CommunityV2,
17422 rotator: &Keys,
17423 recipients: &[PublicKey],
17424 new_root: &[u8; 32],
17425 prev_key: &[u8; 32],
17426 ) {
17427 let new_epoch = Epoch(community.root_epoch.0 + 1);
17428 let prev_epoch = community.root_epoch;
17429 let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
17430 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
17431 let blobs: Vec<_> = recipients
17432 .iter()
17433 .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
17434 .collect();
17435 let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000, my_authority_citation(community, &rotator.public_key()).as_ref(), true).unwrap();
17436 for e in &events {
17437 relay.publish(e, &community.relays).await.unwrap();
17438 }
17439 }
17440
17441 fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
17443 community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
17444 crate::db::community::save_community_v2(community).unwrap();
17445 }
17446
17447 #[tokio::test]
17448 async fn follow_rekeys_is_a_noop_without_rotations() {
17449 let (_tmp, _guard, _owner) = init_test_db();
17450 let relay = MemoryRelay::new();
17451 let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
17452 let session = crate::db::current_session();
17453 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
17454 assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
17455 }
17456
17457 #[tokio::test]
17458 async fn follow_rekeys_adopts_an_owner_base_rotation() {
17459 let (_tmp, _guard, owner) = init_test_db();
17460 let relay = MemoryRelay::new();
17461 let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
17462 let new_root = [0xB1; 32];
17463 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
17465
17466 let session = crate::db::current_session();
17467 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
17468 assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
17469 assert_eq!(updated.community_root, new_root, "adopted the fresh root");
17470 let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
17472 let general = updated.channels[0].id;
17473 let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
17474 assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
17475 }
17476
17477 #[tokio::test]
17478 async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
17479 let (_tmp, _guard, owner) = init_test_db();
17480 let relay = MemoryRelay::new();
17481 let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
17482 let priv_id = ChannelId([0x33; 32]);
17483 add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
17484
17485 let new_key = [0x55; 32];
17487 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
17488 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
17489 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();
17490 let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None, false).unwrap();
17491 for e in &events {
17492 relay.publish(e, &community.relays).await.unwrap();
17493 }
17494
17495 let session = crate::db::current_session();
17496 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
17497 let ch = updated.channel(&priv_id).unwrap();
17498 assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
17499 assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
17500 assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
17501 }
17502
17503 #[tokio::test]
17504 async fn a_whole_channel_fan_rotates_in_one_hop_without_crossing_keys() {
17505 let (_tmp, _guard, owner) = init_test_db();
17511 let relay = MemoryRelay::new();
17512 let mut community = create_community(&relay, "FanRot", vec!["wss://r".into()], None).await.unwrap();
17513
17514 let fan: Vec<(ChannelId, [u8; 32], [u8; 32])> = (0u8..12)
17517 .map(|i| (ChannelId([0x30 + i; 32]), [0x40 + i; 32], [0x80 + i; 32]))
17518 .collect();
17519 for (id, held, _) in &fan {
17520 add_private_channel(&mut community, *id, *held, Epoch(0));
17521 }
17522
17523 for (id, held, fresh) in &fan {
17524 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), held);
17525 let group = channel_rekey_group_key(&community.community_root, id, Epoch(1));
17526 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(*id), Epoch(1), fresh).unwrap();
17527 let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(*id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None, false).unwrap();
17528 for e in &events {
17529 relay.publish(e, &community.relays).await.unwrap();
17530 }
17531 }
17532
17533 let session = crate::db::current_session();
17534 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
17535 for (id, _, fresh) in &fan {
17536 let ch = updated.channel(id).expect("channel survived the fan");
17537 assert_eq!(ch.epoch, Epoch(1), "every channel in the fan advanced");
17538 assert_eq!(ch.key, Some(*fresh), "each channel adopted ITS OWN key, not a neighbour's");
17539 }
17540 }
17541
17542 #[tokio::test]
17543 async fn follow_rekeys_ignores_a_non_owner_rotation() {
17544 let (_tmp, _guard, _owner) = init_test_db();
17547 let relay = MemoryRelay::new();
17548 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
17549 let rogue = Keys::generate();
17550 publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
17551
17552 let session = crate::db::current_session();
17553 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
17554 assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
17555 }
17556
17557 #[tokio::test]
17558 async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
17559 let (_tmp, _guard, owner) = init_test_db();
17562 let relay = MemoryRelay::new();
17563 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
17564 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
17566
17567 let session = crate::db::current_session();
17568 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
17569 assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
17570 }
17571
17572 #[tokio::test]
17573 async fn follow_rekeys_holds_on_an_incomplete_rotation() {
17574 let (_tmp, _guard, owner) = init_test_db();
17577 let relay = MemoryRelay::new();
17578 let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
17579 let new_epoch = Epoch(1);
17580 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
17581 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
17582 let other = Keys::generate();
17584 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
17585 let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None, false).unwrap();
17586 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
17587 relay.publish(&wrap, &community.relays).await.unwrap();
17588
17589 let session = crate::db::current_session();
17590 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
17591 assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
17592 }
17593
17594 #[tokio::test]
17595 async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
17596 let (bed, owner, member) = TestBed::new();
17600 bed.swap_to(&owner);
17601 let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
17602 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
17603
17604 bed.swap_to(&member);
17605 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
17606 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
17607
17608 bed.swap_to(&owner);
17610 let stranger = Keys::generate();
17611 publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
17612
17613 bed.swap_to(&member);
17615 let session = crate::db::current_session();
17616 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
17617 assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
17618 assert!(follow.updated.is_none(), "a removed member adopts nothing");
17619 }
17620
17621 #[tokio::test]
17622 async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
17623 let (_tmp, _guard, owner) = init_test_db();
17629 let relay = MemoryRelay::new();
17630 let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
17631 let root0 = community.community_root;
17632 let priv_id = ChannelId([0x33; 32]);
17633 let key1 = [0x44; 32];
17634 add_private_channel(&mut community, priv_id, key1, Epoch(1));
17635
17636 let key2 = [0x55; 32];
17639 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17640 let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
17641 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();
17642 for e in rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &prev_commit, &[blob], 2_000, None, false).unwrap() {
17643 relay.publish(&e, &community.relays).await.unwrap();
17644 }
17645
17646 community.community_root = [0xB7; 32];
17650 community.root_epoch = Epoch(1);
17651 crate::db::community::save_community_v2(&community).unwrap();
17652
17653 let session = crate::db::current_session();
17654 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
17655 let ch = updated.channel(&priv_id).unwrap();
17656 assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
17657 assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
17658 }
17659
17660 #[tokio::test]
17661 async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
17662 let (_tmp, _guard, owner) = init_test_db();
17667 let relay = MemoryRelay::new();
17668 let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
17669 let priv_id = ChannelId([0x66; 32]);
17670 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() });
17671 crate::db::community::save_community_v2(&community).unwrap();
17672 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17673 assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
17674
17675 let stranger = Keys::generate();
17677 let key1 = [0x71; 32];
17678 let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
17679 let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
17680 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();
17681 for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None, false).unwrap() {
17682 relay.publish(&e, &community.relays).await.unwrap();
17683 }
17684 let key2 = [0x72; 32];
17687 let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17688 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
17689 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();
17690 for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None, false).unwrap() {
17691 relay.publish(&e, &community.relays).await.unwrap();
17692 }
17693
17694 let session = crate::db::current_session();
17697 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
17698 let ch = updated.channel(&priv_id).unwrap();
17699 assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
17700 assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
17701 }
17702
17703 #[tokio::test]
17704 async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
17705 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
17709 let (_tmp, _guard, _owner) = init_test_db();
17710 let relay = MemoryRelay::new();
17711 let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
17712 let priv_id = ChannelId([0x88; 32]);
17713 let key1 = [0x91; 32];
17714 add_private_channel(&mut community, priv_id, key1, Epoch(1));
17715
17716 let admin = Keys::generate();
17718 let role = Role::admin("aa".repeat(32));
17719 let roster = CommunityRoles {
17720 roles: vec![role.clone()],
17721 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
17722 };
17723 seed_roster_with_heads(&community, &roster, 1_000);
17724
17725 let key2 = [0x92; 32];
17727 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17728 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
17729 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
17730 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
17731 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, my_authority_citation(&community, &admin.public_key()).as_ref(), false).unwrap() {
17732 relay.publish(&e, &community.relays).await.unwrap();
17733 }
17734 let session = crate::db::current_session();
17735 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
17736 assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
17737
17738 let rogue = Keys::generate();
17740 let key3 = [0x93; 32];
17741 let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
17742 let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
17743 let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
17744 for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None, false).unwrap() {
17745 relay.publish(&e, &updated.relays).await.unwrap();
17746 }
17747 let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
17748 assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
17749 }
17750
17751 #[tokio::test]
17752 async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
17753 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
17758 let (bed, owner, member) = TestBed::new();
17759 bed.swap_to(&owner);
17760 let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
17761
17762 bed.swap_to(&member);
17765 let mut held = community.clone();
17766 let priv_id = ChannelId([0xAB; 32]);
17767 let key1 = [0xA1; 32];
17768 add_private_channel(&mut held, priv_id, key1, Epoch(1));
17769 let peer = Keys::generate();
17770 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17771 let role = Role::admin("bb".repeat(32));
17772 let roster = CommunityRoles {
17773 roles: vec![role.clone()],
17774 grants: vec![
17775 MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
17776 MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
17777 ],
17778 };
17779 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
17780
17781 let key2 = [0xA2; 32];
17783 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17784 let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
17785 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();
17786 for e in rekey::build_rekey_chunks_local(&peer, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[pb], 2_000, my_authority_citation(&held, &peer.public_key()).as_ref(), false).unwrap() {
17787 bed.relay.publish(&e, &held.relays).await.unwrap();
17788 }
17789 let session = crate::db::current_session();
17790 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
17791 assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
17792 let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
17793 assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
17794
17795 let key3 = [0xA3; 32];
17797 let stranger = Keys::generate();
17798 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();
17799 for e in rekey::build_rekey_chunks_local(&owner.keys, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[ob], 2_100, None, false).unwrap() {
17800 bed.relay.publish(&e, &held.relays).await.unwrap();
17801 }
17802 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
17803 let updated = follow.updated.expect("the owner's removal folds");
17804 assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
17805 }
17806
17807 #[tokio::test]
17808 async fn converting_a_public_channel_to_private_is_refused() {
17809 let (_tmp, _guard, _owner) = init_test_db();
17812 let relay = MemoryRelay::new();
17813 let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
17814 let general = community.channels[0].id;
17815 let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
17816 let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
17817 assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
17818 let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
17820 edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
17821 }
17822
17823 async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
17826 let doc = super::super::list::CommunityList {
17827 entries: vec![],
17828 tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
17829 extra: Default::default(),
17830 };
17831 for (i, frag) in super::super::list_frag::fragment(&doc).iter().enumerate() {
17833 let event = super::super::list_frag::build_fragment_event_keys(me, frag, i, now_ms() / 1000).unwrap();
17834 relay.publish(&event, relays).await.unwrap();
17835 }
17836 }
17837
17838 #[tokio::test]
17839 async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
17840 let (_tmp, _guard, me) = init_test_db();
17844 let relay = MemoryRelay::new();
17845 let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
17846 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
17847
17848 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
17850
17851 let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
17853 republish_community_list(&relay, Some(y.id())).await.unwrap();
17854
17855 let list = fetch_fragments(&relay, &x.relays).await.unwrap().unwrap().list;
17857 assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
17858 assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
17859 }
17860
17861 #[tokio::test]
17862 async fn sync_tears_down_a_community_a_sibling_left() {
17863 let (_tmp, _guard, me) = init_test_db();
17866 let relay = MemoryRelay::new();
17867 let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
17868 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
17869 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
17870
17871 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
17872 sync_community_list(&relay, &x.relays).await.unwrap();
17873 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
17874 }
17875
17876 #[tokio::test]
17877 async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
17878 let (_tmp, _guard, me) = init_test_db();
17881 let relay = MemoryRelay::new();
17882 let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
17883 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
17884 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
17886 republish_community_list(&relay, Some(x.id())).await.unwrap();
17888 sync_community_list(&relay, &x.relays).await.unwrap();
17889 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
17890 }
17891
17892 #[tokio::test]
17893 async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
17894 let (_tmp, _guard, _me) = init_test_db();
17897 let good = MemoryRelay::new();
17898 let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
17899 assert!(fetch_fragments(&good, &community.relays).await.unwrap().is_some());
17900
17901 struct FetchErrors;
17903 #[async_trait::async_trait]
17904 impl Transport for FetchErrors {
17905 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
17906 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
17907 panic!("republish must NOT publish when the remote fetch failed");
17908 }
17909 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
17910 Ok(())
17911 }
17912 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
17913 Err("relay unreachable".to_string())
17914 }
17915 }
17916 republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
17918 }
17919
17920 #[tokio::test]
17921 async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
17922 let (_tmp, _guard, owner) = init_test_db();
17928 let relay = MemoryRelay::new();
17929 let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
17930
17931 let lurker = Keys::generate();
17933 let rid = "b1".repeat(32);
17934 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
17935 publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
17936
17937 let members = memberlist(&relay, &community).await.unwrap();
17939 assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
17940
17941 let banned_grantee = Keys::generate();
17943 publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
17944 set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
17945 let members = memberlist(&relay, &community).await.unwrap();
17946 assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
17947 assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
17948
17949 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
17951 assert_eq!(refounded.root_epoch, Epoch(1));
17952 let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
17953 let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
17954 let rotations = rekey::collect_rotations(&chunks);
17955 let lurker_x = lurker.public_key().to_bytes();
17956 let delivered = rotations.iter().any(|r| {
17957 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
17958 });
17959 assert!(delivered, "the Refounding delivered the new root to the granted lurker");
17960 }
17961
17962 #[tokio::test]
17963 async fn the_memberlist_pages_past_a_guestbook_flood() {
17964 let (_tmp, _guard, _owner) = init_test_db();
17968 let relay = MemoryRelay::new();
17969 let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
17970 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
17971
17972 let honest = Keys::generate();
17974 let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
17975 let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
17976 relay.publish(&w, &community.relays).await.unwrap();
17977 for i in 0..600u64 {
17978 let throwaway = Keys::generate();
17979 let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
17980 let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
17981 relay.publish(&w, &community.relays).await.unwrap();
17982 }
17983
17984 let members = memberlist(&relay, &community).await.unwrap();
17985 assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
17986 }
17987
17988 #[tokio::test]
17989 async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
17990 let (_tmp, _guard, owner) = init_test_db();
17995 let relay = MemoryRelay::new();
17996 let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
17997 let new_root = [0xD9; 32];
17998 let new_epoch = Epoch(1);
17999 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
18000
18001 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
18003
18004 let rogue = Keys::generate();
18007 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
18008 for i in 0..260u64 {
18009 let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
18010 let rumor = rekey::build_rekey_rumor(rogue.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 1, 3_000 + i, None, false).unwrap();
18011 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
18012 relay.publish(&wrap, &community.relays).await.unwrap();
18013 }
18014
18015 let session = crate::db::current_session();
18016 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
18017 assert_eq!(updated.root_epoch, Epoch(1));
18018 assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
18019 }
18020
18021 #[tokio::test]
18022 async fn a_swap_during_create_private_channel_lands_in_the_creating_account() {
18023 let (bed, owner, _member) = TestBed::new();
18032 bed.swap_to(&owner);
18033 let creator = crate::db::current_session();
18034 let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
18035 let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
18036
18037 let swap_relay = SwapMidPublish { inner: MemoryRelay::new() };
18039 create_private_channel(&swap_relay, &community, "ghost").await.unwrap();
18040
18041 let mine = crate::db::with_session(creator, async {
18042 crate::db::community::load_community_v2(community.id()).unwrap().unwrap()
18043 })
18044 .await;
18045 assert_eq!(mine.channels.len(), before + 1, "the channel is minted for the account that asked");
18046 assert!(mine.channels.iter().any(|c| c.name == "ghost"), "and it is the one requested");
18047 }
18048
18049 #[tokio::test]
18050 async fn an_uncited_admin_rotation_is_not_adopted() {
18051 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
18056 let (_tmp, _guard, _owner) = init_test_db();
18057 let relay = MemoryRelay::new();
18058 let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
18059 let priv_id = ChannelId([0x8A; 32]);
18060 let key1 = [0x93; 32];
18061 add_private_channel(&mut community, priv_id, key1, Epoch(1));
18062
18063 let admin = Keys::generate();
18064 let role = Role::admin("cf".repeat(32));
18065 let roster = CommunityRoles {
18066 roles: vec![role.clone()],
18067 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
18068 };
18069 seed_roster_with_heads(&community, &roster, 1_000);
18070
18071 let key2 = [0x94; 32];
18072 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
18073 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
18074 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
18075 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
18076 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None, false).unwrap() {
18078 relay.publish(&e, &community.relays).await.unwrap();
18079 }
18080
18081 let out = follow_rekeys(&relay, &community, &crate::db::current_session()).await.unwrap();
18082 assert!(out.updated.is_none(), "an uncited rotation is not adopted");
18083
18084 let cited = my_authority_citation(&community, &admin.public_key());
18087 assert!(cited.is_some(), "the seeded head yields a citation");
18088 let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
18089 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob2], 2_100, cited.as_ref(), false).unwrap() {
18090 relay.publish(&e, &community.relays).await.unwrap();
18091 }
18092 let out = follow_rekeys(&relay, &community, &crate::db::current_session()).await.unwrap();
18093 assert!(out.updated.is_some(), "the cited rotation IS adopted");
18094 }
18095
18096 #[tokio::test]
18097 async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
18098 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
18106 let (_tmp, _guard, _owner) = init_test_db();
18107 let relay = MemoryRelay::new();
18108 let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
18109 let priv_id = ChannelId([0xC0; 32]);
18110 let key1 = [0xC1; 32];
18111 add_private_channel(&mut community, priv_id, key1, Epoch(1));
18112
18113 let (a, b) = (Keys::generate(), Keys::generate());
18115 let role = Role::admin("ce".repeat(32));
18116 let roster = CommunityRoles {
18117 roles: vec![role.clone()],
18118 grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
18119 };
18120 seed_roster_with_heads(&community, &roster, 1_000);
18121
18122 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
18125 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
18126 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
18127 let key_a = [0x0A; 32];
18128 let key_b = [0xFB; 32]; for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
18130 let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
18131 for e in rekey::build_rekey_chunks_local(signer, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, my_authority_citation(&community, &signer.public_key()).as_ref(), false).unwrap() {
18132 relay.publish(&e, &community.relays).await.unwrap();
18133 }
18134 }
18135
18136 let session = crate::db::current_session();
18137 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
18138 let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
18139 assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
18140
18141 let mut peer = community.clone();
18143 if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
18144 c.key = Some(key1);
18145 c.epoch = Epoch(1);
18146 }
18147 let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
18149 assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
18150 }
18151
18152 #[tokio::test]
18153 async fn create_private_channel_refuses_a_member_without_manage_channels() {
18154 let (bed, owner, member) = TestBed::new();
18157 bed.swap_to(&owner);
18158 let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
18159 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
18160
18161 bed.swap_to(&member);
18162 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
18163 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
18164 let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
18165 assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
18166 let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
18167 assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
18168 }
18169
18170 #[tokio::test]
18173 async fn accept_rejects_a_bundle_with_a_forged_community_root() {
18174 let (bed, owner, member) = TestBed::new();
18178 bed.swap_to(&owner);
18179 let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
18180
18181 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
18182 let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
18183 forged.community_root = fake.clone();
18184 for ch in &mut forged.channels {
18185 ch.key = fake.clone();
18186 }
18187 let attacker = Keys::generate();
18188 let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
18189 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
18190
18191 bed.swap_to(&member);
18192 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
18193 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
18194 assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
18195 assert!(
18196 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
18197 "a rejected join persists nothing"
18198 );
18199 }
18200
18201 #[tokio::test]
18202 async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
18203 let (bed, owner, member) = TestBed::new();
18208 bed.swap_to(&owner);
18209 let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
18210 let general = community.channels[0].id;
18211
18212 let rotated = rotate_view(&community, [0x5A; 32], 1);
18213 let admin = Keys::generate();
18214 publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
18215 publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
18216
18217 bed.swap_to(&member);
18218 let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
18219 let joined = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
18220 assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
18221 }
18222
18223 #[tokio::test]
18224 async fn only_an_actual_join_publishes_a_guestbook_join() {
18225 let (bed, owner, member) = TestBed::new();
18229 bed.swap_to(&owner);
18230 let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
18231
18232 let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
18233 async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
18234 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
18235 relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
18236 }
18237 let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await; bed.swap_to(&member);
18240 let bundle = bundle_of(&community, BundleAudience::Link, None, None, None);
18241 accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
18242 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
18243
18244 accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
18245 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
18246
18247 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18248 crate::db::community::delete_community(&cid_hex).unwrap();
18249 async fn list_id(relay: &MemoryRelay, me: &PublicKey, relays: &[String]) -> Option<String> {
18257 let q = Query { kinds: vec![crate::community::v2::kind::COMMUNITY_LIST_FRAG], authors: vec![me.to_hex()], ..Default::default() };
18258 let mut evs = relay.fetch(&q, relays).await.unwrap_or_default();
18259 evs.sort_by_key(|e| e.created_at.as_secs());
18260 evs.last().map(|e| e.id.to_hex())
18261 }
18262 let my_pk = me_pk().unwrap();
18263 let list_before = list_id(&bed.relay, &my_pk, &bed.relays).await;
18264 accept_bundle(&bed.relay, &bundle, None, false).await.unwrap();
18265 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
18266 assert_eq!(
18267 list_id(&bed.relay, &my_pk, &bed.relays).await,
18268 list_before,
18269 "adopting an entry FROM the list must not republish the list"
18270 );
18271
18272 crate::db::community::delete_community(&cid_hex).unwrap();
18275 accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
18276 assert_ne!(
18277 list_id(&bed.relay, &my_pk, &bed.relays).await,
18278 list_before,
18279 "a real join still records the membership across devices"
18280 );
18281 }
18282
18283 #[tokio::test]
18284 async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
18285 let (bed, owner, member) = TestBed::new();
18288 bed.swap_to(&owner);
18289 let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
18290
18291 let rotated = rotate_view(&community, [0x5B; 32], 1);
18292 let attacker = Keys::generate();
18293 publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
18294
18295 bed.swap_to(&member);
18296 let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
18297 let err = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap_err();
18298 assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
18299 }
18300
18301 #[tokio::test]
18302 async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
18303 let (bed, owner, member) = TestBed::new();
18306 bed.swap_to(&owner);
18307 let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
18308 let general = community.channels[0].id;
18309
18310 let mut fake = community.clone();
18311 fake.community_root = [0x5C; 32]; let admin = Keys::generate();
18313 publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
18314 publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
18315
18316 bed.swap_to(&member);
18317 let bundle = bundle_of(&fake, BundleAudience::Link, None, None, None);
18318 let err = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap_err();
18319 assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
18320 }
18321
18322 #[tokio::test]
18323 async fn follow_control_heals_a_bundle_misclassified_public_channel() {
18324 let (_tmp, _guard, _owner) = init_test_db();
18328 let relay = MemoryRelay::new();
18329 let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
18330 let general = community.channels[0].id;
18331 let mut poisoned = community.clone();
18332 poisoned.channels[0].private = true;
18333 poisoned.channels[0].key = Some([0x66; 32]);
18334 crate::db::community::save_community_v2(&poisoned).unwrap();
18335
18336 let healed = follow_control(&relay, &poisoned).await.unwrap().updated.expect("healed");
18337 let ch = healed.channel(&general).unwrap();
18338 assert!(!ch.private, "the owner's public declaration overrides the bundle");
18339 assert_eq!(ch.key, None, "a healed public channel derives from the root");
18340 }
18341
18342 #[tokio::test]
18343 async fn a_deleted_channel_does_not_resurrect_on_reload() {
18344 let (_tmp, _guard, owner) = init_test_db();
18347 let relay = MemoryRelay::new();
18348 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
18349 let extra = ChannelId([0x77; 32]);
18350 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
18351 let with_extra = follow_control(&relay, &community).await.unwrap().updated.unwrap();
18352 assert!(with_extra.channel(&extra).is_some());
18353 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
18354 let after = follow_control(&relay, &with_extra).await.unwrap().updated.unwrap();
18355 assert!(after.channel(&extra).is_none());
18356
18357 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18358 assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
18359 assert_eq!(reloaded.channels.len(), 1);
18360 }
18361
18362 #[tokio::test]
18363 async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
18364 let (_tmp, _guard, _owner) = init_test_db();
18368 let relay = MemoryRelay::new();
18369 let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
18370 let a_channel = a.channels[0].id;
18371 let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
18372 let b_channel = b.channels[0].id;
18373 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() });
18375
18376 crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
18377 let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
18379 assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
18380 assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
18381 let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
18383 assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
18384 assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
18385 }
18386
18387 struct CappedRelay {
18393 events: Vec<Event>,
18394 cap: usize,
18395 }
18396 #[async_trait::async_trait]
18397 impl Transport for CappedRelay {
18398 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
18399 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
18400 Ok(())
18401 }
18402 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
18403 Ok(())
18404 }
18405 async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
18406 let mut m: Vec<Event> = self
18407 .events
18408 .iter()
18409 .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
18410 .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
18411 .cloned()
18412 .collect();
18413 m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
18415 Ok(m)
18416 }
18417 }
18418
18419 #[tokio::test]
18420 async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
18421 let (_tmp, _guard, _owner) = init_test_db();
18428 let memory = MemoryRelay::new();
18429 let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
18430 let control = control::ControlPlane::of(&community).write_group().unwrap();
18434
18435 let rogue = Keys::generate();
18436 let mut events: Vec<Event> = Vec::new();
18437 for i in 0..FOLLOW_PAGE {
18438 let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
18439 let rumor = control::build_edition_rumor(
18440 rogue.public_key(),
18441 vsk::CHANNEL_METADATA,
18442 &[0xAB; 32],
18443 1,
18444 None,
18445 &content,
18446 9_000,
18447 None,
18448 );
18449 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
18450 events.push(w);
18451 }
18452 let relay = CappedRelay { events, cap: FOLLOW_PAGE };
18453
18454 let err = refound_community(&relay, &community, &[])
18455 .await
18456 .expect_err("a plane that can't be read whole must never be compacted");
18457 assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
18458 }
18459
18460 #[tokio::test]
18461 async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
18462 let (_tmp, _guard, owner) = init_test_db();
18467 let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
18468 let g = control::genesis(&owner, meta, 1_000).unwrap();
18469 let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
18470
18471 let control = control::ControlPlane::of(&community).write_group().unwrap();
18472 let rogue = Keys::generate();
18473 let mut events: Vec<Event> = g.wraps.to_vec();
18474 for i in 0..250u64 {
18475 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
18476 let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
18477 events.push(wrap);
18478 }
18479 let relay = CappedRelay { events, cap: 100 };
18481 let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
18482 assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
18483 }
18484
18485 #[tokio::test]
18486 async fn accept_parked_invite_joins_from_the_stored_bundle() {
18487 let (bed, owner, member) = TestBed::new();
18490 bed.swap_to(&owner);
18491 let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
18492 let general = community.channels[0].id;
18493 send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
18494 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
18495 let bundle_json = serde_json::to_string(&bundle).unwrap();
18496 let inviter_hex = owner.keys.public_key().to_hex();
18497
18498 bed.swap_to(&member);
18499 let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
18500 assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
18501 assert!(joined.identity.verify());
18502 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
18503 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
18506 assert!(
18507 crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
18508 "the joiner's control floor is seeded from the join-time fold"
18509 );
18510
18511 bed.swap_to(&owner);
18513 let members = memberlist(&bed.relay, &community).await.unwrap();
18514 assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
18515 }
18516
18517 #[tokio::test]
18518 async fn accept_parked_invite_rejects_a_forged_root() {
18519 let (_tmp, _guard, _owner) = init_test_db();
18523 let relay = MemoryRelay::new();
18524 let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
18525 let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
18526 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
18527 forged.community_root = fake.clone();
18528 for ch in &mut forged.channels {
18529 ch.key = fake.clone();
18530 }
18531 let bundle_json = serde_json::to_string(&forged).unwrap();
18532
18533 let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
18534 assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
18535 }
18536
18537 #[test]
18538 fn v2_and_v1_bundles_are_distinguishable_by_parse() {
18539 let owner = Keys::generate();
18543 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
18544 let hex = crate::simd::hex::bytes_to_hex_32;
18545 let v2 = invite::CommunityInvite {
18546 community_id: hex(&identity.community_id.0),
18547 owner: hex(&identity.owner_xonly),
18548 owner_salt: hex(&identity.owner_salt),
18549 community_root: hex(&[0x11; 32]),
18550 root_epoch: 0,
18551 control_pk: None,
18552 channels: vec![],
18553 relays: vec!["wss://r".into()],
18554 name: "V2".into(),
18555 icon: None,
18556 expires_at: None,
18557 creator_npub: None,
18558 label: None,
18559 extra: Default::default(),
18560 };
18561 let v2_json = serde_json::to_string(&v2).unwrap();
18562 assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
18563 let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
18564 assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
18565 }
18566
18567 #[tokio::test]
18568 async fn verify_rejects_a_cross_community_owner_edition_replay() {
18569 let (_tmp, _guard, owner) = init_test_db();
18575
18576 let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
18578 let x_control = control::split_write_group(&gx.control_root, &gx.community_root, &gx.identity.community_id, Epoch(0));
18579 let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
18580
18581 let t_identity = control::CommunityIdentity::mint(&owner.public_key());
18583 let fake_root = [0xEE; 32];
18584 let t = CommunityV2 {
18585 identity: t_identity,
18586 community_root: fake_root,
18587 root_epoch: Epoch(0),
18588 control_pk: None,
18589 control_root: None,
18590 name: "T".into(),
18591 description: None,
18592 icon: None,
18593 banner: None,
18594 meta_custom: None,
18595 meta_extra: Default::default(),
18596 relays: vec!["wss://r".into()],
18597 channels: vec![],
18598 dissolved: false,
18599 created_at_ms: 0,
18600 };
18601 let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
18604 let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
18605 let relay = MemoryRelay::new();
18606 relay.publish(&replayed, &t.relays).await.unwrap();
18607
18608 let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
18609 assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
18610 }
18611
18612 #[tokio::test]
18620 #[ignore = "hits a real relay over the network"]
18621 async fn live_smoke_create_send_fetch_on_a_real_relay() {
18622 use crate::community::transport::LiveTransport;
18623 use nostr_sdk::prelude::ToBech32;
18624
18625 let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
18626 let relays = vec![relay.clone()];
18627
18628 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
18630 crate::db::close_database();
18631 crate::db::clear_id_caches();
18632 let tmp = tempfile::tempdir().unwrap();
18633 let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
18636 Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
18637 Err(_) => Keys::generate(),
18638 };
18639 let npub = keys.public_key().to_bech32().unwrap();
18640 if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
18643 println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
18644 }
18645 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
18646 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
18647 crate::db::set_current_account(npub.clone()).unwrap();
18648 crate::db::init_database(&npub).unwrap();
18649 crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
18650 crate::state::set_my_public_key(keys.public_key());
18651 println!("[smoke] throwaway identity {npub}");
18652
18653 let client = crate::nostr_client_builder().build();
18655 client.add_managed_relay(relay.as_str()).await.ok();
18656 client.connect().await;
18657 crate::state::set_nostr_client(client);
18658 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
18659
18660 let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
18662 let general = community.channels[0].id;
18663 println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
18664
18665 let text = "hello from a Vector Concord v2 live smoke test";
18666 let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
18667 println!("[smoke] sent message {sent_id}");
18668
18669 crate::rt::time::sleep(std::time::Duration::from_secs(3)).await;
18671
18672 let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
18673 let texts: Vec<String> = page
18674 .iter()
18675 .filter_map(|f| match &f.event {
18676 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
18677 _ => None,
18678 })
18679 .collect();
18680 println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
18681 assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
18682
18683 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
18685 println!("[smoke] invite link: {}", link.url);
18686 println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
18687 }
18688
18689 #[tokio::test]
18690 async fn chat_ops_react_edit_delete_round_trip() {
18691 let (bed, owner, _member) = TestBed::new();
18692 bed.swap_to(&owner);
18693 let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
18694 let general = community.channels[0].id;
18695 let me_hex = owner.keys.public_key().to_hex();
18696
18697 let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
18698 send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
18699 .await
18700 .unwrap();
18701 send_edit(&bed.relay, &community, &general, &msg_id, "edited", &[]).await.unwrap();
18702 send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
18703
18704 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18705 let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
18706 let mut saw = (false, false, false);
18707 for f in &page {
18708 match &f.event {
18709 ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
18710 assert_eq!(emoji, ":fire:");
18711 assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
18712 saw.0 = true;
18713 }
18714 ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
18715 assert_eq!(new_content, "edited");
18716 saw.1 = true;
18717 }
18718 ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
18719 _ => {}
18720 }
18721 }
18722 assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
18723 }
18724
18725 #[tokio::test]
18726 async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
18727 let (bed, owner, _member) = TestBed::new();
18728 bed.swap_to(&owner);
18729 let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
18730 let general = community.channels[0].id;
18731 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
18732
18733 let mut sub = bed.relay.subscribe(Query {
18735 kinds: vec![stream::KIND_WRAP_EPHEMERAL],
18736 authors: vec![group.pk_hex()],
18737 ..Default::default()
18738 });
18739 send_typing(&bed.relay, &community, &general).await.unwrap();
18740 let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
18741 let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
18742 Ok(ChatEvent::Typing { opened }) => opened,
18743 other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
18744 };
18745
18746 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18749 assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
18750
18751 assert!(
18753 crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
18754 "ephemeral sends must not retain scrub keys"
18755 );
18756 }
18757
18758 #[tokio::test]
18759 async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
18760 let (bed, owner, _member) = TestBed::new();
18761 bed.swap_to(&owner);
18762 let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
18763 let general = community.channels[0].id;
18764 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
18765
18766 let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
18767
18768 let (keys, outer_hex, relays) =
18771 crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
18772 assert_eq!(relays, community.relays);
18773 let wrap_query = Query {
18774 kinds: vec![stream::KIND_WRAP],
18775 authors: vec![group.pk_hex()],
18776 ..Default::default()
18777 };
18778 let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
18779 let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
18780 assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
18781
18782 let me_hex = owner.keys.public_key().to_hex();
18784 let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
18785 .await
18786 .unwrap();
18787 assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
18788
18789 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
18792 assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
18793 let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
18794 assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
18795 }
18796
18797 #[tokio::test]
18798 async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
18799 let (bed, owner, _member) = TestBed::new();
18800 bed.swap_to(&owner);
18801 let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
18802 let general = community.channels[0].id;
18803 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
18804
18805 let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
18808 crate::db::community::delete_message_key(&id).unwrap();
18809 assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
18810
18811 let mkeys = Keys::generate();
18813 let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
18814 let foreign_id = rumor.id.unwrap().to_hex();
18815 let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
18816 bed.relay.publish(&fw, &community.relays).await.unwrap();
18817
18818 fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18820 let (keys, _outer, relays) =
18821 crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
18822 assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
18823 assert_eq!(relays, community.relays);
18824
18825 assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
18827
18828 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
18830 let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18831 assert!(
18832 !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
18833 "healed message scrubbed from the relay"
18834 );
18835 }
18836
18837 #[tokio::test]
18838 async fn send_chat_message_threads_the_reply_and_extra_tags() {
18839 let (bed, owner, _member) = TestBed::new();
18840 bed.swap_to(&owner);
18841 let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
18842 let general = community.channels[0].id;
18843 let me_hex = owner.keys.public_key().to_hex();
18844
18845 let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
18846 let imeta = nostr_sdk::prelude::Tag::custom(
18847 "imeta",
18848 ["url https://e/blob".to_string(), "m image/png".to_string()],
18849 );
18850 let child_id = send_chat_message(
18851 &bed.relay, &community, &general, "child",
18852 Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
18853 )
18854 .await
18855 .unwrap();
18856
18857 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18858 let child = page
18859 .iter()
18860 .find_map(|f| match &f.event {
18861 ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
18862 _ => None,
18863 })
18864 .expect("the reply message round-trips");
18865 let reply = child.1.as_ref().expect("the reply reference is carried");
18866 assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
18867 assert_eq!(reply.author, Some(owner.keys.public_key()));
18868 assert!(
18869 child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
18870 "the imeta attachment tag rides the rumor verbatim"
18871 );
18872 }
18873
18874 #[tokio::test]
18875 async fn a_kick_needs_kick_authority_and_removes_the_target() {
18876 let (bed, owner, member) = TestBed::new();
18877 bed.swap_to(&owner);
18878 let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
18879
18880 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
18882 let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
18883 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
18884 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
18885 let before = memberlist(&bed.relay, &community).await.unwrap();
18886 assert!(before.contains(&member.keys.public_key()), "the join lands first");
18887
18888 bed.swap_to(&member);
18890 let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
18891 assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
18892
18893 bed.swap_to(&owner);
18895 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
18896 let after = memberlist(&bed.relay, &community).await.unwrap();
18897 assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
18898 assert!(after.contains(&owner.keys.public_key()), "the owner remains");
18899 }
18900
18901 #[tokio::test]
18902 async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
18903 let (bed, owner, member) = TestBed::new();
18908 bed.swap_to(&owner);
18909 let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
18910 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18911 let (o, m) = (owner.keys.public_key(), member.keys.public_key());
18912 let join = |at: u64, id: u8| guestbook::GuestbookEvent { epoch: None, observed_at: None,
18913 rumor_id: [id; 32],
18914 entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
18915 };
18916 let kick = |at: u64, id: u8| guestbook::GuestbookEvent { epoch: None, observed_at: None,
18917 rumor_id: [id; 32],
18918 entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
18919 };
18920
18921 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
18923 assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
18924
18925 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
18927 assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
18928
18929 crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
18931 assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
18932
18933 assert!(
18936 !stored_memberlist(&community).unwrap().contains(&m),
18937 "the memberlist excludes an un-caught-up member — why it can't gate a kick"
18938 );
18939 }
18940
18941 fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
18946 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18947 crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
18948 for g in &roster.grants {
18949 let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
18950 let eid = super::super::derive::grant_locator(community.id(), &m);
18951 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
18952 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
18953 }
18954 }
18955
18956 async fn publish_grant_citing(
18958 relay: &MemoryRelay,
18959 community: &CommunityV2,
18960 signer: &Keys,
18961 member: &PublicKey,
18962 role_ids: Vec<String>,
18963 version: u64,
18964 citation: Option<&crate::community::edition::AuthorityCitation>,
18965 ) {
18966 let group = control::ControlPlane::of(&community).write_group().unwrap();
18967 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
18968 let prev = head_hash_on_relay(relay, community, &eid).await;
18969 let grant = MemberGrant { member: member.to_hex(), role_ids };
18970 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
18971 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
18972 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
18973 relay.publish(&wrap, &community.relays).await.unwrap();
18974 }
18975
18976 #[tokio::test]
18977 async fn an_uncited_edition_cannot_self_promote_or_outlive_a_demotion() {
18978 let (bed, owner, admin) = TestBed::new();
18982 bed.swap_to(&owner);
18983 let community = create_community(&bed.relay, "NoEscalate", bed.relays.clone(), None).await.unwrap();
18984 let admin_pk = admin.keys.public_key();
18985
18986 let mid = "d1".repeat(32);
18988 let mut mid_role = admin_role(&mid, Permissions::admin().0);
18989 mid_role.position = 3;
18990 publish_role(&bed.relay, &community, &owner.keys, &mid_role, 1).await;
18991 let high = "d2".repeat(32);
18992 let mut high_role = admin_role(&high, Permissions::admin().0);
18993 high_role.position = 1;
18994 publish_role(&bed.relay, &community, &owner.keys, &high_role, 1).await;
18995 publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![mid.clone()], 1).await;
18996
18997 publish_grant_citing(&bed.relay, &community, &admin.keys, &admin_pk, vec![high.clone(), mid.clone()], 2, None).await;
18999 let view = fetch_authority(&bed.relay, &community).await;
19002 assert!(
19003 !view.roles.grants.iter().any(|g| g.member == admin_pk.to_hex() && g.role_ids.contains(&high)),
19004 "an uncited edition must not let an author grant themselves a role they do not \
19005 outrank — you cannot act at or above your own rank, citation or not",
19006 );
19007
19008 let bystander = Keys::generate().public_key();
19012 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![mid.clone()], 1, None).await;
19013 let before = fetch_authority(&bed.relay, &community).await;
19014 assert!(
19015 !before.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
19016 "an admin at position 3 cannot grant position 3 — equal cannot act on equal",
19017 );
19018
19019 publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![], 3).await;
19020 let after = fetch_authority(&bed.relay, &community).await;
19021 assert!(
19022 !after.roles.is_admin(&admin_pk.to_hex()),
19023 "the owner's demotion folds",
19024 );
19025 assert!(
19026 !after.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
19027 "and nothing the demoted author wrote is honored afterwards — authority is \
19028 resolved against the CURRENT roster, which is what the citation was only ever \
19029 a scheduling hint for",
19030 );
19031 }
19032
19033 #[tokio::test]
19034 async fn an_uncited_edition_folds_on_rank_alone_and_never_escalates() {
19035 let (bed, owner, admin) = TestBed::new();
19046 bed.swap_to(&owner);
19047 let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
19048 let admin_pk = admin.keys.public_key();
19049 let rid = "c3".repeat(32);
19050 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
19051 publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
19052
19053 let low_rid = "c4".repeat(32);
19055 let mut low = admin_role(&low_rid, Permissions::admin().0);
19056 low.position = 5;
19057 publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
19058
19059 let bystander = Keys::generate().public_key();
19061 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
19062 let view = fetch_authority(&bed.relay, &community).await;
19063 assert!(
19064 view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
19065 "an uncited edition from an author the roster DOES authorize, acting beneath \
19066 their own rank, is genuine and must fold",
19067 );
19068 assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant still folds");
19069
19070 let stranger = Keys::generate();
19072 let victim = Keys::generate().public_key();
19073 publish_grant_citing(&bed.relay, &community, &stranger, &victim, vec![low_rid.clone()], 1, None).await;
19074 let view = fetch_authority(&bed.relay, &community).await;
19075 assert!(
19076 !view.roles.grants.iter().any(|g| g.member == victim.to_hex()),
19077 "an uncited edition from an UNAUTHORIZED author must never fold — dropping the \
19078 citation requirement must not become a way in",
19079 );
19080
19081 let peer = Keys::generate().public_key();
19084 publish_grant_citing(&bed.relay, &community, &admin.keys, &peer, vec![rid.clone()], 1, None).await;
19085 let view = fetch_authority(&bed.relay, &community).await;
19086 assert!(
19087 !view.roles.grants.iter().any(|g| g.member == peer.to_hex()),
19088 "an authorized author still cannot grant AT their own rank — rank is what \
19089 stops escalation, and it is untouched",
19090 );
19091
19092 publish_grant_citing(&bed.relay, &community, &stranger, &bystander, vec![], 9, None).await;
19097 let view = fetch_authority(&bed.relay, &community).await;
19098 assert!(
19099 view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
19100 "an uncited empty grant must NOT strip a member — a revocation's rank check is \
19101 vacuous, so tolerating it uncited hands any stranger a demotion primitive",
19102 );
19103 }
19104
19105 #[tokio::test]
19106 async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
19107 let (bed, owner, member) = TestBed::new();
19112 bed.swap_to(&owner);
19113 let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
19114 let member_pk = member.keys.public_key();
19115 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19116
19117 crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
19119 crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
19120
19121 let join = guestbook::GuestbookEvent { epoch: None, observed_at: None,
19123 rumor_id: [9u8; 32],
19124 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
19125 };
19126 assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
19127 assert!(
19128 !stored_memberlist(&community).unwrap().contains(&member_pk),
19129 "while banned, the fold keeps them out"
19130 );
19131
19132 crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
19134 assert!(
19135 stored_memberlist(&community).unwrap().contains(&member_pk),
19136 "after the unban the raced Join makes them a member again"
19137 );
19138 }
19139
19140 #[tokio::test]
19141 async fn a_stale_root_admin_write_is_refused_not_misdirected() {
19142 let (bed, owner, member) = TestBed::new();
19148 bed.swap_to(&owner);
19149 let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
19150 let member_pk = member.keys.public_key();
19151
19152 set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
19153 let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
19154
19155 let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
19157 assert!(err.contains("re-founded"), "unban: {err}");
19158 let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
19160 assert!(err.contains("re-founded"), "invite: {err}");
19161 let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
19163 assert!(err.contains("re-founded"), "kick: {err}");
19164
19165 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19167 set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
19168 let view = fetch_authority(&bed.relay, &fresh).await;
19169 assert!(view.banned.is_empty(), "the retried unban actually unbans");
19170 }
19171
19172 struct YieldyRelay<'a>(&'a MemoryRelay);
19177 #[async_trait::async_trait]
19178 impl Transport for YieldyRelay<'_> {
19179 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
19180 crate::rt::yield_now().await;
19181 self.0.publish(event, relays).await
19182 }
19183 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
19184 crate::rt::yield_now().await;
19185 self.0.fetch(query, relays).await
19186 }
19187 async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
19188 crate::rt::yield_now().await;
19189 self.0.fetch_plane(plane, query, relays).await
19190 }
19191 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
19192 crate::rt::yield_now().await;
19193 self.0.publish_durable(event, relays).await
19194 }
19195 }
19196
19197 #[tokio::test]
19198 async fn concurrent_bans_compose_instead_of_erasing_each_other() {
19199 let (bed, owner, _member) = TestBed::new();
19204 bed.swap_to(&owner);
19205 let community = create_community(&bed.relay, "Wave", bed.relays.clone(), None).await.unwrap();
19206 let a = Keys::generate().public_key();
19207 let b = Keys::generate().public_key();
19208
19209 let yieldy = YieldyRelay(&bed.relay);
19210 let (wave_a, wave_b) = ([a], [b]);
19211 let (ra, rb) = tokio::join!(
19212 set_members_banned(&yieldy, community.id(), &wave_a, true),
19213 set_members_banned(&yieldy, community.id(), &wave_b, true),
19214 );
19215 ra.unwrap();
19216 rb.unwrap();
19217
19218 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19219 assert_eq!(fresh.root_epoch, Epoch(2), "each single ban still performed its own read cut");
19220 let view = fetch_authority(&bed.relay, &fresh).await;
19221 assert!(
19222 view.banned.contains(&a.to_hex()) && view.banned.contains(&b.to_hex()),
19223 "both racing bans landed: {:?}",
19224 view.banned
19225 );
19226 }
19227
19228 #[tokio::test]
19229 async fn bans_stacked_behind_a_rotation_coalesce_into_one_read_cut() {
19230 let (bed, owner, _member) = TestBed::new();
19235 bed.swap_to(&owner);
19236 let community = create_community(&bed.relay, "Stack", bed.relays.clone(), None).await.unwrap();
19237 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19238 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
19239 let (a, b, c, d) = (
19240 Keys::generate().public_key(),
19241 Keys::generate().public_key(),
19242 Keys::generate().public_key(),
19243 Keys::generate().public_key(),
19244 );
19245
19246 let yieldy = YieldyRelay(&bed.relay);
19247 let (wa, wb, wc, wd) = ([a], [b], [c], [d]);
19248 let (ra, rb, rc, rd) = tokio::join!(
19251 set_members_banned(&yieldy, community.id(), &wa, true),
19252 set_members_banned(&yieldy, community.id(), &wb, true),
19253 set_members_banned(&yieldy, community.id(), &wc, true),
19254 set_members_banned(&yieldy, community.id(), &wd, true),
19255 );
19256 ra.unwrap();
19257 rb.unwrap();
19258 rc.unwrap();
19259 rd.unwrap();
19260
19261 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19262 assert_eq!(fresh.root_epoch, Epoch(2), "leader rotation + ONE coalesced rotation, never four");
19263 let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
19264 assert_eq!(version, 2, "two editions total: the leader's and the coalesced drain's");
19265 let view = fetch_authority(&bed.relay, &fresh).await;
19266 for pk in [a, b, c, d] {
19267 assert!(view.banned.contains(&pk.to_hex()), "{} missing from the fold", pk.to_hex());
19268 }
19269 }
19270
19271 #[tokio::test]
19272 async fn a_stacked_ban_and_unban_coalesce_in_arrival_order() {
19273 let (bed, owner, _member) = TestBed::new();
19277 bed.swap_to(&owner);
19278 let community = create_community(&bed.relay, "Net", bed.relays.clone(), None).await.unwrap();
19279 let x = Keys::generate().public_key();
19280 let y = Keys::generate().public_key();
19281
19282 let yieldy = YieldyRelay(&bed.relay);
19283 let (wx, wy) = ([x], [y]);
19284 let (rx1, ry, rx2) = tokio::join!(
19285 set_members_banned(&yieldy, community.id(), &wx, true), set_members_banned(&yieldy, community.id(), &wy, true), set_members_banned(&yieldy, community.id(), &wx, false), );
19289 rx1.unwrap();
19290 ry.unwrap();
19291 rx2.unwrap();
19292
19293 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19294 assert_eq!(fresh.root_epoch, Epoch(2), "X's rotation + one coalesced rotation for Y");
19295 let view = fetch_authority(&bed.relay, &fresh).await;
19296 assert!(!view.banned.contains(&x.to_hex()), "the stacked unban netted X out");
19297 assert!(view.banned.contains(&y.to_hex()), "Y's stacked ban landed");
19298 }
19299
19300 #[tokio::test]
19301 async fn a_second_member_folds_every_stacked_ban() {
19302 let (bed, owner, member) = TestBed::new();
19307 bed.swap_to(&owner);
19308 let community = create_community(&bed.relay, "Agree", bed.relays.clone(), None).await.unwrap();
19309 let a = Keys::generate().public_key();
19310 let b = Keys::generate().public_key();
19311
19312 let yieldy = YieldyRelay(&bed.relay);
19313 let (wa, wb) = ([a], [b]);
19314 let (ra, rb) = tokio::join!(
19315 set_members_banned(&yieldy, community.id(), &wa, true),
19316 set_members_banned(&yieldy, community.id(), &wb, true),
19317 );
19318 ra.unwrap();
19319 rb.unwrap();
19320
19321 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19323 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
19324 let bundle_json = serde_json::to_string(&bundle).unwrap();
19325
19326 bed.swap_to(&member);
19327 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
19328 let _ = follow_control(&bed.relay, &joined).await;
19329 let view = fetch_authority(&bed.relay, &joined).await;
19330 assert!(
19331 view.banned.contains(&a.to_hex()) && view.banned.contains(&b.to_hex()),
19332 "an independent reader folds BOTH stacked bans: {:?}",
19333 view.banned
19334 );
19335 }
19336
19337 #[tokio::test]
19338 async fn a_net_zero_batch_publishes_no_edition() {
19339 let (bed, owner, _member) = TestBed::new();
19342 bed.swap_to(&owner);
19343 let community = create_community(&bed.relay, "Noop", bed.relays.clone(), None).await.unwrap();
19344 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19345 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
19346 let x = Keys::generate().public_key();
19347
19348 let decoy = Keys::generate().public_key();
19351 let yieldy = YieldyRelay(&bed.relay);
19352 let (wd, wx) = ([decoy], [x]);
19353 let (rd, r1, r2) = tokio::join!(
19354 set_members_banned(&yieldy, community.id(), &wd, true),
19355 set_members_banned(&yieldy, community.id(), &wx, true),
19356 set_members_banned(&yieldy, community.id(), &wx, false),
19357 );
19358 rd.unwrap();
19359 r1.unwrap();
19360 r2.unwrap();
19361
19362 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19363 assert_eq!(fresh.root_epoch, Epoch(1), "only the decoy's rotation — the net-zero drain rotated nothing");
19364 let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
19365 assert_eq!(version, 1, "only the decoy's edition — the net-zero drain published nothing");
19366 let view = fetch_authority(&bed.relay, &fresh).await;
19367 assert!(!view.banned.contains(&x.to_hex()));
19368 assert!(view.banned.contains(&decoy.to_hex()));
19369 }
19370
19371 struct BrokenPublish<'a>(&'a MemoryRelay);
19373 #[async_trait::async_trait]
19374 impl Transport for BrokenPublish<'_> {
19375 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
19376 crate::rt::yield_now().await;
19377 Err("relay refused".to_string())
19378 }
19379 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
19380 crate::rt::yield_now().await;
19381 self.0.fetch(query, relays).await
19382 }
19383 async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
19384 crate::rt::yield_now().await;
19385 self.0.fetch_plane(plane, query, relays).await
19386 }
19387 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
19388 crate::rt::yield_now().await;
19389 Err("relay refused".to_string())
19390 }
19391 }
19392
19393 #[tokio::test]
19394 async fn a_failed_publish_answers_every_stacked_caller_and_never_hangs() {
19395 let (bed, owner, _member) = TestBed::new();
19399 bed.swap_to(&owner);
19400 let community = create_community(&bed.relay, "Dead", bed.relays.clone(), None).await.unwrap();
19401 let a = Keys::generate().public_key();
19402 let b = Keys::generate().public_key();
19403
19404 let broken = BrokenPublish(&bed.relay);
19405 let (wa, wb) = ([a], [b]);
19406 let (ra, rb) = crate::rt::time::timeout(
19407 std::time::Duration::from_secs(30),
19408 async {
19409 tokio::join!(
19410 set_members_banned(&broken, community.id(), &wa, true),
19411 set_members_banned(&broken, community.id(), &wb, true),
19412 )
19413 },
19414 )
19415 .await
19416 .expect("stacked callers must resolve, never hang");
19417 assert!(ra.is_err(), "the leader surfaced the publish failure");
19418 assert!(rb.is_err(), "the stacked caller surfaced it too");
19419
19420 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19422 let view = fetch_authority(&bed.relay, &fresh).await;
19423 assert!(view.banned.is_empty(), "no half-published state: {:?}", view.banned);
19424 assert_eq!(fresh.root_epoch, Epoch(0), "no rotation on a failed edition");
19425 }
19426
19427 #[tokio::test]
19428 async fn a_poison_intent_fails_alone_not_its_batchmates() {
19429 let (bed, owner, _member) = TestBed::new();
19432 bed.swap_to(&owner);
19433 let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
19434 let filler: Vec<String> = (0..498).map(|i| format!("{i:064x}")).collect();
19437 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19438 set_banlist(&bed.relay, &held, &filler).await.unwrap();
19439 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19440 crate::db::community::set_community_banlist(&cid_hex, &filler, 1).unwrap();
19441
19442 let (a, x, y) = (Keys::generate().public_key(), Keys::generate().public_key(), Keys::generate().public_key());
19443 let yieldy = YieldyRelay(&bed.relay);
19444 let (wa, wx, wy) = ([a], [x], [y]);
19445 let (ra, rx, ry) = tokio::join!(
19446 set_members_banned(&yieldy, community.id(), &wa, true),
19447 set_members_banned(&yieldy, community.id(), &wx, true),
19448 set_members_banned(&yieldy, community.id(), &wy, true),
19449 );
19450 ra.unwrap();
19451 rx.unwrap();
19452 let err = ry.unwrap_err();
19453 assert!(err.contains("ceiling"), "the over-cap intent failed alone: {err}");
19454
19455 let list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
19456 assert_eq!(list.len(), 500, "the batch published up to the ceiling");
19457 assert!(list.contains(&x.to_hex()) && !list.contains(&y.to_hex()));
19458 }
19459
19460 #[tokio::test]
19461 async fn ban_many_is_one_edition_one_refound_for_the_whole_wave() {
19462 let (bed, owner, _member) = TestBed::new();
19463 bed.swap_to(&owner);
19464 let community = create_community(&bed.relay, "Purge", bed.relays.clone(), None).await.unwrap();
19465 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19466 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
19467 let wave: Vec<PublicKey> = (0..3).map(|_| Keys::generate().public_key()).collect();
19468
19469 set_members_banned(&bed.relay, community.id(), &wave, true).await.unwrap();
19470
19471 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19472 assert_eq!(fresh.root_epoch, Epoch(1), "one refound for the whole wave, not one per target");
19473 let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
19474 assert_eq!(version, 1, "one banlist edition carried the whole wave");
19475 let view = fetch_authority(&bed.relay, &fresh).await;
19476 for pk in &wave {
19477 assert!(view.banned.contains(&pk.to_hex()), "{} missing from the fold", pk.to_hex());
19478 }
19479
19480 set_members_banned(&bed.relay, community.id(), &wave[..2], false).await.unwrap();
19482 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19483 assert_eq!(fresh.root_epoch, Epoch(1), "an unban rotates nothing");
19484 let view = fetch_authority(&bed.relay, &fresh).await;
19485 assert_eq!(view.banned.len(), 1, "reductive: exactly the un-unbanned target remains");
19486 assert!(view.banned.contains(&wave[2].to_hex()));
19487 }
19488
19489 #[tokio::test]
19490 async fn a_batch_past_the_banlist_cap_refuses_before_publishing() {
19491 let (bed, owner, _member) = TestBed::new();
19492 bed.swap_to(&owner);
19493 let community = create_community(&bed.relay, "Cap", bed.relays.clone(), None).await.unwrap();
19494 let wave: Vec<PublicKey> = (0..=crate::community::v2::roles::MAX_BANLIST).map(|_| Keys::generate().public_key()).collect();
19495
19496 let err = set_members_banned(&bed.relay, community.id(), &wave, true).await.unwrap_err();
19497 assert!(err.contains("ceiling"), "refused at the cap: {err}");
19498 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19499 assert_eq!(fresh.root_epoch, Epoch(0), "nothing rotated");
19500 let view = fetch_authority(&bed.relay, &fresh).await;
19501 assert!(view.banned.is_empty(), "nothing published");
19502 }
19503
19504 #[tokio::test]
19505 async fn an_uncited_kick_from_an_admin_is_not_honored() {
19506 let (bed, owner, member) = TestBed::new();
19511 bed.swap_to(&owner);
19512 let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
19513 let admin = Keys::generate();
19514 let member_pk = member.keys.public_key();
19515 grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
19516
19517 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19518 let view = fetch_authority(&bed.relay, &community).await;
19519 crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
19520
19521 let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
19522 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
19523 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
19524 let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
19525
19526 let joined = guestbook::GuestbookEvent { epoch: None, observed_at: None,
19527 rumor_id: [1u8; 32],
19528 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
19529 };
19530 let kick = |citation, id: u8, at| guestbook::GuestbookEvent { epoch: None, observed_at: None,
19531 rumor_id: [id; 32],
19532 entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
19533 };
19534 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
19535 let empty_bans = std::collections::BTreeSet::new();
19536 let empty_marks = std::collections::BTreeMap::new();
19537 let fold = |evs: &[guestbook::GuestbookEvent]| {
19538 fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
19539 };
19540
19541 assert!(
19542 fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
19543 "an uncited kick from an admin is not honored"
19544 );
19545 assert!(
19546 !fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
19547 "the same kick WITH its synced citation removes them"
19548 );
19549 }
19550
19551 #[tokio::test]
19552 async fn kicking_an_admin_strips_their_roles_first() {
19553 let (bed, owner, member) = TestBed::new();
19557 bed.swap_to(&owner);
19558 let community = create_community(&bed.relay, "Compose", bed.relays.clone(), None).await.unwrap();
19559 let member_pk = member.keys.public_key();
19560 let member_hex = member_pk.to_hex();
19561 let owner_hex = owner.keys.public_key().to_hex();
19562
19563 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
19564 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
19565
19566 kick_member(&bed.relay, &community, &member_pk).await.unwrap();
19567
19568 let view = fetch_authority(&bed.relay, &community).await;
19569 assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
19570 assert!(
19571 !view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
19572 "a kicked admin holds no bit"
19573 );
19574 assert!(
19575 !memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
19576 "and the directive still removed them"
19577 );
19578 }
19579
19580 #[tokio::test]
19581 async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
19582 let (bed, owner, member) = TestBed::new();
19583 bed.swap_to(&owner);
19584 let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
19585 let member_pk = member.keys.public_key();
19586 let member_hex = member_pk.to_hex();
19587 let owner_hex = owner.keys.public_key().to_hex();
19588
19589 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
19590 let view = fetch_authority(&bed.relay, &community).await;
19591 assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
19592 assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
19593
19594 let second = Keys::generate().public_key();
19597 grant_admin(&bed.relay, &community, &second).await.unwrap();
19598 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
19599 let view = fetch_authority(&bed.relay, &community).await;
19600 assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
19601 assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
19602 let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
19603 assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
19604
19605 revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
19607 let view = fetch_authority(&bed.relay, &community).await;
19608 assert!(!view.roles.is_admin(&member_hex), "revoked");
19609 assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
19610 assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
19611 }
19612
19613 #[tokio::test]
19614 async fn follow_control_persists_the_roster_for_sync_local_reads() {
19615 let (bed, owner, member) = TestBed::new();
19616 bed.swap_to(&owner);
19617 let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
19618 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19619 let member_hex = member.keys.public_key().to_hex();
19620 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
19621
19622 follow_control(&bed.relay, &community).await.unwrap().updated;
19624 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
19625 assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
19626
19627 let withholding = MemoryRelay::new();
19630 let _ = follow_control(&withholding, &community).await;
19631 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
19632 assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
19633
19634 revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
19636 follow_control(&bed.relay, &community).await.unwrap().updated;
19637 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
19638 assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
19639 }
19640
19641 #[tokio::test]
19642 async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
19643 let (bed, owner, member) = TestBed::new();
19644 bed.swap_to(&owner);
19645 let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
19646
19647 bed.swap_to(&member);
19648 let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
19649 assert!(err.contains("owner"), "refused before any publish: {err}");
19650
19651 bed.swap_to(&owner);
19654 let view = fetch_authority(&bed.relay, &community).await;
19655 assert!(view.roles.roles.is_empty(), "no role edition landed");
19656 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
19657 let view = fetch_authority(&bed.relay, &community).await;
19658 assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
19659 }
19660
19661 #[tokio::test]
19662 async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
19663 let (bed, owner, member) = TestBed::new();
19664 bed.swap_to(&owner);
19665 let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
19666 let member_pk = member.keys.public_key();
19667
19668 let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
19671 set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
19672 grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
19673
19674 let withholding = MemoryRelay::new();
19677 let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
19678 assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
19679
19680 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
19682 let view = fetch_authority(&bed.relay, &community).await;
19683 let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
19684 assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
19685 assert!(grant.role_ids.contains(&mod_rid));
19686 }
19687
19688 #[tokio::test]
19689 async fn fetch_authority_reflects_a_granted_admin() {
19690 let (bed, owner, member) = TestBed::new();
19691 bed.swap_to(&owner);
19692 let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
19693 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
19694 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
19695 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
19696
19697 let view = fetch_authority(&bed.relay, &community).await;
19698 let member_hex = member.keys.public_key().to_hex();
19699 assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
19700 assert!(
19701 view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
19702 "an ADMIN_ALL grant carries KICK"
19703 );
19704 assert!(view.banned.is_empty());
19705 }
19706
19707 #[tokio::test]
19712 async fn a_pin_edition_folds_persists_and_reads_back() {
19713 let (_tmp, _guard, _owner) = init_test_db();
19714 let relay = MemoryRelay::new();
19715 let community = create_community(&relay, "Pinsville", vec!["wss://r".into()], None).await.unwrap();
19716 let general = community.channels[0].id;
19717 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19718
19719 send_message(&relay, &community, &general, "pin-worthy").await.unwrap();
19720 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19721 let opened = page
19722 .iter()
19723 .find_map(|f| match &f.event {
19724 ChatEvent::Message { opened, .. } => Some(opened.clone()),
19725 _ => None,
19726 })
19727 .expect("the sent message reads back");
19728
19729 let ch = community.channels[0].clone();
19730 let conv = channel_conv_key_at(&community, &ch, 0).expect("owner holds the public plane key");
19731 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19732 let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
19733
19734 let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
19735 publish_control_edition(&relay, &community, vsk::PINS, &eid, &content).await.unwrap();
19736 follow_control(&relay, &community).await.unwrap().updated;
19737
19738 let read = read_channel_pins(&community, &general).unwrap();
19739 assert!(!read.sealed);
19740 assert!(read.version >= 1, "the folded head persisted");
19741 assert_eq!(read.pins.len(), 1);
19742 assert_eq!(read.pins[0].content, "pin-worthy");
19743 assert_eq!(read.pins[0].rumor_id, opened.rumor_id.to_hex());
19744 }
19745
19746 #[tokio::test]
19747 async fn a_fresh_join_sees_pins_and_the_banlist_without_waiting_for_a_follow() {
19748 let (bed, owner, member) = TestBed::new();
19753 bed.swap_to(&owner);
19754 let community = create_community(&bed.relay, "PinJoin", bed.relays.clone(), None).await.unwrap();
19755 let general = community.channels[0].id;
19756 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19757
19758 send_message(&bed.relay, &community, &general, "pin-worthy").await.unwrap();
19759 let page = fetch_channel(&bed.relay, &community, &general, 10).await.unwrap();
19760 let opened = page
19761 .iter()
19762 .find_map(|f| match &f.event {
19763 ChatEvent::Message { opened, .. } => Some(opened.clone()),
19764 _ => None,
19765 })
19766 .expect("the sent message reads back");
19767 let ch = community.channels[0].clone();
19768 let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
19769 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19770 let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
19771 let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
19772 publish_control_edition(&bed.relay, &community, vsk::PINS, &eid, &content).await.unwrap();
19773 let spammer = Keys::generate().public_key().to_hex();
19774 set_banlist(&bed.relay, &community, std::slice::from_ref(&spammer)).await.unwrap();
19775 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
19776 let bundle_json = serde_json::to_string(&bundle).unwrap();
19777
19778 bed.swap_to(&member);
19779 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
19780 let read = read_channel_pins(&joined, &general).unwrap();
19782 assert!(!read.sealed);
19783 assert_eq!(read.pins.len(), 1, "pins visible the instant the join returns");
19784 assert_eq!(read.pins[0].content, "pin-worthy");
19785 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
19786 let banlist = crate::db::community::get_community_banlist(&cid_hex).unwrap();
19787 assert!(banlist.contains(&spammer), "the banlist is enforced from the join, not the first follow");
19788 }
19789
19790 #[tokio::test]
19793 async fn an_unauthorized_pin_edition_never_folds() {
19794 let (_tmp, _guard, _owner) = init_test_db();
19795 let relay = MemoryRelay::new();
19796 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
19797 let general = community.channels[0].id;
19798
19799 let rogue = Keys::generate();
19800 let group = control::ControlPlane::of(&community).write_group().unwrap();
19801 let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
19802 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::PINS, &eid, 1, None, r#"{"entries":[]}"#, 2_000, None);
19803 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(2_000)).unwrap();
19804 relay.publish(&wrap, &community.relays).await.unwrap();
19805
19806 follow_control(&relay, &community).await.unwrap().updated;
19807 let read = read_channel_pins(&community, &general).unwrap();
19808 assert_eq!(read.version, 0, "an unauthorized edition never persists a head");
19809 assert!(read.pins.is_empty());
19810 }
19811
19812 #[tokio::test]
19815 async fn owner_silently_widens_a_legacy_admin_role() {
19816 let (_tmp, _guard, owner) = init_test_db();
19817 let relay = MemoryRelay::new();
19818 let community = create_community(&relay, "Legacy", vec!["wss://r".into()], None).await.unwrap();
19819 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19820 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5d; 32]);
19821
19822 let legacy = Role {
19824 role_id: rid.clone(),
19825 name: "Admin".into(),
19826 position: 1,
19827 permissions: Permissions(Permissions::ADMIN_FOUNDING_MASK),
19828 scope: RoleScope::Server,
19829 color: 0, extra: Default::default(),
19830 };
19831 publish_role(&relay, &community, &owner, &legacy, 1).await;
19832 follow_control(&relay, &community).await.unwrap().updated;
19833
19834 assert!(upgrade_admin_role_pin_bit(&relay, &community).await.unwrap(), "the widening publishes");
19835 follow_control(&relay, &community).await.unwrap().updated;
19836 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
19837 let widened = roles.roles.iter().find(|r| r.role_id == rid).expect("same entity");
19838 assert!(widened.permissions.contains(Permissions::PIN_MESSAGES), "bit 11 landed");
19839 assert!(widened.permissions.contains(Permissions::ADMIN_FOUNDING_MASK), "nothing stripped");
19840
19841 assert!(!upgrade_admin_role_pin_bit(&relay, &community).await.unwrap());
19843 }
19844
19845 #[tokio::test]
19848 async fn the_deletion_duty_omits_a_pinned_message() {
19849 let (_tmp, _guard, _owner) = init_test_db();
19850 let relay = MemoryRelay::new();
19851 let community = create_community(&relay, "Duties", vec!["wss://r".into()], None).await.unwrap();
19852 let general = community.channels[0].id;
19853 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19854
19855 let rumor_id = send_message(&relay, &community, &general, "soon deleted").await.unwrap();
19856 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19857 let opened = page
19858 .iter()
19859 .find_map(|f| match &f.event {
19860 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
19861 _ => None,
19862 })
19863 .unwrap();
19864 let ch = community.channels[0].clone();
19865 let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
19866 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19867 publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
19868 assert_eq!(read_channel_pins(&community, &general).unwrap().pins.len(), 1);
19869
19870 run_pin_duty(&relay, &ch_hex, &rumor_id, None).await.unwrap();
19872 let after = read_channel_pins(&community, &general).unwrap();
19873 assert!(after.pins.is_empty(), "the omitting edition landed");
19874 assert!(after.version >= 2, "a NEW edition, not a local erase");
19875 }
19876
19877 #[tokio::test]
19880 async fn the_edit_duty_refreshes_a_pinned_proof() {
19881 let (_tmp, _guard, _owner) = init_test_db();
19882 let relay = MemoryRelay::new();
19883 let community = create_community(&relay, "Edits", vec!["wss://r".into()], None).await.unwrap();
19884 let general = community.channels[0].id;
19885 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19886
19887 let rumor_id = send_message(&relay, &community, &general, "first words").await.unwrap();
19888 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19889 let opened = page
19890 .iter()
19891 .find_map(|f| match &f.event {
19892 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
19893 _ => None,
19894 })
19895 .unwrap();
19896 let ch = community.channels[0].clone();
19897 let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
19898 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19899 publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
19900
19901 send_edit(&relay, &community, &general, &rumor_id, "second thoughts", &[]).await.unwrap();
19902 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19903 let edit_opened = page
19904 .iter()
19905 .find_map(|f| match &f.event {
19906 ChatEvent::Edit { opened, .. } => Some(opened.clone()),
19907 _ => None,
19908 })
19909 .expect("the edit reads back");
19910
19911 run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened.clone())).await.unwrap();
19912 let after = read_channel_pins(&community, &general).unwrap();
19913 assert_eq!(after.pins.len(), 1);
19914 assert_eq!(
19915 after.pins[0].content, "second thoughts",
19916 "the refreshed bundle proves the revision"
19917 );
19918 let v = after.version;
19919
19920 run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened)).await.unwrap();
19922 assert_eq!(read_channel_pins(&community, &general).unwrap().version, v, "idempotent");
19923 }
19924
19925 #[tokio::test]
19930 async fn an_own_edit_echo_carries_the_pin_duty_trigger() {
19931 let (_tmp, _guard, _owner) = init_test_db();
19932 let relay = MemoryRelay::new();
19933 let community = create_community(&relay, "OwnEdit", vec!["wss://r".into()], None).await.unwrap();
19934 let general = community.channels[0].id;
19935
19936 let rumor_id = send_message(&relay, &community, &general, "before").await.unwrap();
19937 send_edit(&relay, &community, &general, &rumor_id, "after", &[]).await.unwrap();
19938 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19939 let edit_event = &page.iter().find(|f| matches!(&f.event, ChatEvent::Edit { .. })).expect("the edit reads back").event;
19940 let msg_event = &page.iter().find(|f| matches!(&f.event, ChatEvent::Message { .. })).expect("the message reads back").event;
19941
19942 let updated = crate::community::v2::inbound::ChatPersist::Updated {
19943 message: crate::types::Message::default(),
19944 edit_event: None,
19945 };
19946 let (target_hex, opened) = own_echo_pin_duty(&updated, edit_event).expect("an applied own edit fires the duty");
19947 assert_eq!(target_hex, rumor_id, "the duty targets the EDITED message, not the edit rumor");
19948 assert_ne!(opened.rumor_id.to_hex(), rumor_id, "and carries the edit's own opened stream for the bundle");
19949
19950 let new = crate::community::v2::inbound::ChatPersist::New(crate::types::Message::default());
19952 assert!(own_echo_pin_duty(&new, msg_event).is_none());
19953 assert!(own_echo_pin_duty(&updated, msg_event).is_none());
19955 }
19956
19957 #[tokio::test]
19961 async fn a_rotation_reseals_the_pin_list_under_the_new_epoch() {
19962 let (_tmp, _guard, _owner) = init_test_db();
19963 let relay = MemoryRelay::new();
19964 let community = create_community(&relay, "Reseal", vec!["wss://r".into()], None).await.unwrap();
19965 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19966 let chan = create_private_channel(&relay, &community, "vault").await.unwrap();
19967 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19968 let ch_hex = crate::simd::hex::bytes_to_hex_32(&chan.0);
19969
19970 let rumor_id = send_message(&relay, &community, &chan, "sealed wisdom").await.unwrap();
19971 let page = fetch_channel(&relay, &community, &chan, 10).await.unwrap();
19972 let opened = page
19973 .iter()
19974 .find_map(|f| match &f.event {
19975 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
19976 _ => None,
19977 })
19978 .unwrap();
19979 let ch = community.channel(&chan).unwrap().clone();
19980 let old_epoch = ch.epoch.0;
19981 let conv = channel_conv_key_at(&community, &ch, old_epoch).unwrap();
19982 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19983 publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
19984 let (_, v_before) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
19985
19986 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
19988 rekey_channel_excluding(&relay, &community, &chan, &roster, &[], &[Keys::generate().public_key()])
19989 .await
19990 .unwrap();
19991
19992 let (content, version) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
19994 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
19995 assert_eq!(
19996 parsed["epoch"].as_str().unwrap(),
19997 (old_epoch + 1).to_string(),
19998 "the reseal names the rotated epoch"
19999 );
20000 assert!(version > v_before, "a real edition, not a local rewrite");
20001
20002 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
20004 let read = read_channel_pins(&community, &chan).unwrap();
20005 assert!(!read.sealed);
20006 assert_eq!(read.pins.len(), 1);
20007 assert_eq!(read.pins[0].content, "sealed wisdom");
20008 }
20009
20010 #[tokio::test]
20016 async fn a_published_banlist_echoes_locally_before_any_fold() {
20017 let (_tmp, _guard, _owner) = init_test_db();
20018 let relay = MemoryRelay::new();
20019 let community = create_community(&relay, "Modtown", vec!["wss://r".into()], None).await.unwrap();
20020 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
20021 let spammer_a = "aa".repeat(32);
20022 let spammer_b = "bb".repeat(32);
20023
20024 set_banlist(&relay, &community, &[spammer_a.clone()]).await.unwrap();
20026 assert_eq!(
20027 crate::db::community::get_community_banlist(&cid_hex).unwrap(),
20028 vec![spammer_a.clone()],
20029 "the publish echoes without waiting for a fold"
20030 );
20031
20032 let mut list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
20034 list.push(spammer_b.clone());
20035 set_banlist(&relay, &community, &list).await.unwrap();
20036 let held = crate::db::community::get_community_banlist(&cid_hex).unwrap();
20037 assert!(
20038 held.contains(&spammer_a) && held.contains(&spammer_b),
20039 "sequential bans UNION; the second must not erase the first: {held:?}"
20040 );
20041
20042 follow_control(&relay, &community).await.unwrap().updated;
20044 let folded = crate::db::community::get_community_banlist(&cid_hex).unwrap();
20045 assert!(folded.contains(&spammer_a) && folded.contains(&spammer_b));
20046 }
20047}