1use nostr_sdk::prelude::{FinalizeEvent, FinalizeEventAsync, FinalizeUnsignedEvent};
15use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp};
16
17use super::super::transport::{Query, Transport};
18use super::super::{version, ChannelId, Epoch};
19use super::chat::{self, ChatEvent};
20use super::community::{ChannelV2, CommunityV2};
21use super::control;
22use super::derive::{base_rekey_group_key, channel_group_key, channel_rekey_group_key, control_group_key, GroupKey};
23use super::invite::{self, CommunityInvite};
24use super::rekey::{self, Continuity, RekeyScope};
25use super::{guestbook, stream, vsk};
26use crate::community::edition::ParsedEdition;
27use crate::state::SessionGuard;
28use crate::ClientRelayExt;
29
30fn me_pk() -> Result<PublicKey, String> {
41 crate::state::my_public_key().ok_or_else(|| "no active identity".to_string())
42}
43
44fn now_ms() -> u64 {
45 std::time::SystemTime::now()
46 .duration_since(std::time::UNIX_EPOCH)
47 .map(|d| d.as_millis() as u64)
48 .unwrap_or(0)
49}
50
51pub async fn create_community<T: Transport + ?Sized>(
56 transport: &T,
57 name: &str,
58 relays: Vec<String>,
59 description: Option<String>,
60) -> Result<CommunityV2, String> {
61 let session = SessionGuard::capture();
62 let signer = crate::signer::active_signer()?;
63 let owner_pk = me_pk()?;
64 let at_ms = now_ms();
65
66 let meta = control::CommunityMetadata {
67 name: name.to_string(),
68 description: description.clone(),
69 relays: relays.clone(),
70 ..Default::default()
71 };
72 let genesis = control::genesis_signed(owner_pk, &signer, meta, at_ms / 1000).await.map_err(|e| e.to_string())?;
73 let community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
74
75 if !session.is_valid() {
80 return Err("account changed during community creation".to_string());
81 }
82 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
88 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
89 for wrap in &genesis.wraps {
90 if let Ok((ed, _)) = control::open_control_edition(wrap, &control) {
91 let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
92 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
93 }
94 }
95 crate::db::community::save_community_v2(&community)?;
96 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
99
100 for wrap in &genesis.wraps {
105 transport.publish_durable(wrap, &community.relays).await?;
106 }
107
108 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
112 let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
113 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
114 let _ = transport.publish_durable(&join_wrap, &community.relays).await;
115 }
116
117 match republish_community_list(transport, Some(community.id())).await {
119 Ok(true) => {}
120 Ok(false) => republish_community_list_durable(Some(*community.id())),
121 Err(e) => {
122 crate::log_warn!("[CommunityList] failed to record this community across devices ({}) — retrying", e);
123 republish_community_list_durable(Some(*community.id()));
124 }
125 }
126 Ok(community)
127}
128
129pub async fn create_migration_twin<T: Transport + ?Sized>(
134 transport: &T,
135 name: &str,
136 relays: Vec<String>,
137 description: Option<String>,
138 primary: (ChannelId, String),
139) -> Result<CommunityV2, String> {
140 let session = SessionGuard::capture();
141 let signer = crate::signer::active_signer()?;
142 let owner_pk = me_pk()?;
143 let at_ms = now_ms();
144
145 let meta = control::CommunityMetadata {
146 name: name.to_string(),
147 description: description.clone(),
148 relays: relays.clone(),
149 ..Default::default()
150 };
151 let primary_name = primary.1.clone();
152 let genesis = control::genesis_signed_with_primary(owner_pk, &signer, meta, at_ms / 1000, Some(primary))
153 .await
154 .map_err(|e| e.to_string())?;
155 let mut community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
156 if let Some(ch) = community.channels.first_mut() {
159 ch.name = primary_name;
160 }
161 if !session.is_valid() {
162 return Err("account changed during twin creation".to_string());
163 }
164 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
165 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
166 for wrap in &genesis.wraps {
167 if let Ok((ed, _)) = control::open_control_edition(wrap, &control) {
168 let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
169 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
170 }
171 }
172 crate::db::community::save_community_v2(&community)?;
173 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
174 for wrap in &genesis.wraps {
175 transport.publish_durable(wrap, &community.relays).await?;
176 }
177 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
179 let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
180 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
181 let _ = transport.publish_durable(&join_wrap, &community.relays).await;
182 }
183 Ok(community)
184}
185
186pub async fn clone_banlist_to_twin<T: Transport + ?Sized>(
190 transport: &T,
191 twin: &CommunityV2,
192 banned: &[String],
193) -> Result<(), String> {
194 if banned.is_empty() {
195 return Ok(());
196 }
197 set_banlist(transport, twin, banned).await
198}
199
200pub async fn clone_governance_to_twin<T: Transport + ?Sized>(
211 transport: &T,
212 twin: &CommunityV2,
213 v1_roles: &crate::community::roles::CommunityRoles,
214 banned: &[String],
215) -> Result<(), String> {
216 use crate::community::roles::Permissions;
217 let owner = twin.owner()?;
218 for grant in &v1_roles.grants {
219 if !v1_roles.effective_permissions(&grant.member).contains(Permissions::ADMIN_ALL) {
220 continue; }
222 if banned.contains(&grant.member) {
223 continue; }
225 let Ok(member) = PublicKey::parse(&grant.member) else { continue };
226 if member == owner {
227 continue; }
229 grant_admin(transport, twin, &member).await?;
230 }
231 Ok(())
232}
233
234pub fn twin_join_material(twin: &CommunityV2) -> super::list::JoinMaterial {
236 join_material(twin)
237}
238
239pub async fn send_message<T: Transport + ?Sized>(
243 transport: &T,
244 community: &CommunityV2,
245 channel_id: &ChannelId,
246 content: &str,
247) -> Result<String, String> {
248 send_chat_message(transport, community, channel_id, content, None, &[], vec![]).await
249}
250
251pub async fn send_chat_message<T: Transport + ?Sized>(
255 transport: &T,
256 community: &CommunityV2,
257 channel_id: &ChannelId,
258 content: &str,
259 reply_to: Option<(&str, &str)>,
260 emoji: &[(&str, &str)],
261 extra_tags: Vec<nostr_sdk::prelude::Tag>,
262) -> Result<String, String> {
263 send_chat_message_at(transport, community, channel_id, content, reply_to, emoji, extra_tags, now_ms()).await
264}
265
266#[allow(clippy::too_many_arguments)]
271pub async fn send_chat_message_at<T: Transport + ?Sized>(
272 transport: &T,
273 community: &CommunityV2,
274 channel_id: &ChannelId,
275 content: &str,
276 reply_to: Option<(&str, &str)>,
277 emoji: &[(&str, &str)],
278 extra_tags: Vec<nostr_sdk::prelude::Tag>,
279 at_ms: u64,
280) -> Result<String, String> {
281 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
282 let rumor = chat::build_message_rumor(author_pk, channel_id, epoch, content, reply_to, emoji, extra_tags, at_ms);
283 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
284}
285
286#[allow(clippy::too_many_arguments)]
291pub async fn send_reaction<T: Transport + ?Sized>(
292 transport: &T,
293 community: &CommunityV2,
294 channel_id: &ChannelId,
295 target_id_hex: &str,
296 target_author_hex: &str,
297 target_kind: u16,
298 emoji_content: &str,
299 emoji: Option<(&str, &str)>,
300) -> Result<String, String> {
301 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
302 let at_ms = now_ms();
303 let rumor =
304 chat::build_reaction_rumor(author_pk, channel_id, epoch, target_id_hex, target_author_hex, target_kind, emoji_content, emoji, at_ms);
305 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
306}
307
308pub async fn send_edit<T: Transport + ?Sized>(
312 transport: &T,
313 community: &CommunityV2,
314 channel_id: &ChannelId,
315 target_id_hex: &str,
316 new_content: &str,
317) -> Result<String, String> {
318 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
319 let at_ms = now_ms();
320 let rumor = chat::build_edit_rumor(author_pk, channel_id, epoch, target_id_hex, new_content, at_ms);
321 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
322}
323
324pub async fn send_delete<T: Transport + ?Sized>(
328 transport: &T,
329 community: &CommunityV2,
330 channel_id: &ChannelId,
331 target_id_hex: &str,
332 target_kind: u16,
333) -> Result<String, String> {
334 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
335 let at_ms = now_ms();
336 let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, None);
337 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
338}
339
340pub async fn moderation_delete<T: Transport + ?Sized>(
350 transport: &T,
351 community: &CommunityV2,
352 channel_id: &ChannelId,
353 target_id_hex: &str,
354 target_kind: u16,
355 target_author: &PublicKey,
356) -> Result<String, String> {
357 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
358 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
359 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
360 return Err("this community is dissolved — it accepts no new moderation actions".to_string());
361 }
362 let owner_hex = community.owner()?.to_hex();
363 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
364 if !crate::community::moderation::can_hide(
365 Some(&owner_hex),
366 &roster,
367 &author_pk.to_hex(),
368 &target_author.to_hex(),
369 ) {
370 return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
371 }
372 let at_ms = now_ms();
373 let citation = required_authority_citation(community, &author_pk)?;
374 let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, citation.as_ref());
375 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
376}
377
378pub async fn send_webxdc_signal<T: Transport + ?Sized>(
385 transport: &T,
386 community: &CommunityV2,
387 channel_id: &ChannelId,
388 topic_id: &str,
389 node_addr: Option<&str>,
390) -> Result<(), String> {
391 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
392 let at_ms = now_ms();
393 let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
394 let rumor = chat::build_webxdc_rumor(author_pk, channel_id, epoch, &content, vec![], at_ms);
395 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await.map(|_| ())
396}
397
398pub async fn send_typing<T: Transport + ?Sized>(
400 transport: &T,
401 community: &CommunityV2,
402 channel_id: &ChannelId,
403) -> Result<(), String> {
404 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
405 let at_ms = now_ms();
406 let rumor = chat::build_typing_rumor(author_pk, channel_id, epoch, at_ms);
407 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
408}
409
410fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(PublicKey, GroupKey, Epoch, SessionGuard), String> {
416 let session = SessionGuard::capture();
417 let author_pk = me_pk()?;
418 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
419 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
420 return Err("this community has been dissolved".to_string());
421 }
422 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author_pk.to_hex()) {
426 return Err("you are banned from this community".to_string());
427 }
428 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
429 if ch.private && ch.key.is_none() {
430 return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
431 }
432 let (secret, epoch) = community.channel_secret(ch);
433 Ok((author_pk, channel_group_key(&secret, channel_id, epoch), epoch, session))
434}
435
436#[allow(clippy::too_many_arguments)]
439async fn publish_chat<T: Transport + ?Sized>(
440 transport: &T,
441 community: &CommunityV2,
442 session: &SessionGuard,
443 group: &GroupKey,
444 author_pk: PublicKey,
445 channel_id: &ChannelId,
446 epoch: Epoch,
447 rumor: nostr_sdk::prelude::UnsignedEvent,
448 at_ms: u64,
449 ephemeral: bool,
450) -> Result<String, String> {
451 let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
452 let signer = crate::signer::active_signer()?;
453 let (wrap, _p_tag_keys) = chat::seal_chat_rumor_signed(&signer, author_pk, &rumor, group, Timestamp::from_secs(at_ms / 1000), ephemeral).await
454 .map_err(|e| e.to_string())?;
455 if !session.is_valid() {
456 return Err("account changed before send".to_string());
457 }
458 transport.publish(&wrap, &community.relays).await?;
459 if !ephemeral {
465 if !session.is_valid() {
466 return Ok(rumor_id);
467 }
468 crate::db::community::store_message_key(&rumor_id, &wrap.id.to_hex(), group.keys(), &community.relays)?;
469 }
470 if !ephemeral {
475 if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
476 let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
477 let outcome = {
478 let mut st = crate::state::STATE.lock().await;
479 if !session.is_valid() {
480 return Ok(rumor_id); }
482 super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author_pk)
483 };
484 if let Some(outcome) = outcome {
485 if !session.is_valid() {
486 return Ok(rumor_id);
487 }
488 super::inbound::persist_chat(&channel_hex, &outcome).await;
489 }
490 }
491 }
492 Ok(rumor_id)
493}
494
495pub struct FetchedEvent {
498 pub event: ChatEvent,
499 pub epoch: Epoch,
500}
501
502fn heal_own_wrap_key(event: &ChatEvent, group: &GroupKey, relays: &[String]) {
509 if !matches!(event, ChatEvent::Message { .. } | ChatEvent::Reaction { .. }) {
510 return;
511 }
512 let opened = event.opened();
513 if crate::state::my_public_key() != Some(opened.author) {
514 return;
515 }
516 let rumor_hex = opened.rumor_id.to_hex();
517 if !matches!(crate::db::community::get_message_key(&rumor_hex), Ok(None)) {
520 return;
521 }
522 if crate::db::community::store_message_key(&rumor_hex, &opened.wrapper_id.to_hex(), group.keys(), relays).is_ok() {
523 crate::traits::emit_event("message_delete_meta_changed", &serde_json::json!({ "id": rumor_hex }));
526 }
527}
528
529pub async fn fetch_channel<T: Transport + ?Sized>(
533 transport: &T,
534 community: &CommunityV2,
535 channel_id: &ChannelId,
536 limit: usize,
537) -> Result<Vec<FetchedEvent>, String> {
538 fetch_channel_history(transport, community, channel_id, limit, 1, None, crate::community::transport::Evidence::Quorum, |_| true).await
539}
540
541pub async fn fetch_channel_history<T: Transport + ?Sized>(
555 transport: &T,
556 community: &CommunityV2,
557 channel_id: &ChannelId,
558 page: usize,
559 max_pages: usize,
560 since: Option<u64>,
561 evidence: crate::community::transport::Evidence,
562 mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
563) -> Result<Vec<FetchedEvent>, String> {
564 let session = SessionGuard::capture();
567 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
568 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
573 let coords: Vec<([u8; 32], Epoch)> = if ch.private {
574 let Some(current) = ch.key else {
575 return Ok(Vec::new());
576 };
577 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
578 let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
579 if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
580 held.push((ch.epoch, current));
581 }
582 held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
585 } else {
586 let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
587 if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
588 roots.push((community.root_epoch, community.community_root));
589 }
590 roots.into_iter().map(|(ep, root)| (root, ep)).collect()
591 };
592 if coords.is_empty() {
593 return Ok(Vec::new());
594 }
595
596 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
597 let mut seen_rumors = std::collections::HashSet::new();
598 let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
599 let mut until: Option<u64> = None;
600 let mut oldest: Option<u64> = None;
601 for _ in 0..max_pages {
602 let mut wraps: Vec<Event> = Vec::new();
608 let mut wrap_ids: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
609 for (secret, epoch) in &coords {
610 let plane = channel_group_key(secret, channel_id, *epoch);
611 let q = Query {
612 kinds: vec![stream::KIND_WRAP],
613 authors: vec![plane.pk_hex()],
614 since,
615 until,
616 limit: Some(page),
617 evidence,
618 ..Default::default()
619 };
620 if let Ok(evs) = transport.fetch_plane(plane.keys(), &q, &community.relays).await {
621 for e in evs {
622 if wrap_ids.insert(e.id) {
623 wraps.push(e);
624 }
625 }
626 }
627 }
628 if wraps.is_empty() {
629 break;
630 }
631 let mut fresh = 0usize;
632 let mut page_events: Vec<FetchedEvent> = Vec::new();
633 for wrap in &wraps {
634 if !seen_wraps.insert(wrap.id) {
635 continue;
636 }
637 fresh += 1;
638 let at = wrap.created_at.as_secs();
639 if oldest.is_none_or(|o| at < o) {
640 oldest = Some(at);
641 }
642 for (secret, epoch) in &coords {
644 let group = channel_group_key(secret, channel_id, *epoch);
645 if wrap.pubkey != group.pk() {
646 continue;
647 }
648 if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
649 let id = event.opened().rumor_id;
650 if seen_rumors.insert(id) {
651 if session.is_valid() {
652 heal_own_wrap_key(&event, &group, &community.relays);
653 }
654 page_events.push(FetchedEvent { event, epoch: *epoch });
655 }
656 }
657 break;
658 }
659 }
660 if fresh == 0 {
661 if wraps.len() < page {
662 break; }
664 let Some(o) = oldest else { break };
668 if o == 0 {
669 break;
670 }
671 crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
672 until = Some(o - 1);
673 continue;
674 }
675 let stop = !page_events.is_empty() && !keep_paging(&page_events);
676 out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
677 if stop {
678 break; }
680 until = oldest; }
682 out.sort_by_key(|(ms, _)| *ms);
683 Ok(out.into_iter().map(|(_, e)| e).collect())
684}
685
686pub fn bundle_of(
693 community: &CommunityV2,
694 creator: Option<PublicKey>,
695 expires_at_ms: Option<u64>,
696 label: Option<String>,
697) -> CommunityInvite {
698 let hex = crate::simd::hex::bytes_to_hex_32;
699 let channels = community
700 .channels
701 .iter()
702 .filter(|c| !(c.private && c.key.is_none()))
707 .map(|c| invite::ChannelGrant {
708 id: hex(&c.id.0),
709 key: hex(&c.key.unwrap_or(community.community_root)),
710 epoch: c.epoch.0,
711 name: c.name.clone(),
712 })
713 .collect();
714 CommunityInvite {
715 community_id: hex(&community.identity.community_id.0),
716 owner: hex(&community.identity.owner_xonly),
717 owner_salt: hex(&community.identity.owner_salt),
718 community_root: hex(&community.community_root),
719 root_epoch: community.root_epoch.0,
720 channels,
721 relays: community.relays.clone(),
722 name: community.name.clone(),
723 icon: community.icon.clone(),
726 expires_at: expires_at_ms,
727 creator_npub: creator.map(|p| p.to_hex()),
728 label,
729 extra: Default::default(),
730 }
731}
732
733pub async fn send_direct_invite<T: Transport + ?Sized>(
739 transport: &T,
740 community: &CommunityV2,
741 recipient: &PublicKey,
742 expires_at_ms: Option<u64>,
743 label: Option<String>,
744) -> Result<Event, String> {
745 let session = SessionGuard::capture();
746 assert_current_root(community)?;
749 let signer = crate::signer::active_signer()?;
750 let inviter_pk = me_pk()?;
751 let bundle = bundle_of(community, Some(inviter_pk), expires_at_ms, label);
752 let wrap = invite::build_direct_invite_signed(&signer, inviter_pk, recipient, &bundle).await.map_err(|e| e.to_string())?;
753 if !session.is_valid() {
754 return Err("account changed before sending invite".to_string());
755 }
756 transport.publish(&wrap, &community.relays).await?;
757 Ok(wrap)
758}
759
760pub struct MintedLink {
764 pub url: String,
765 pub bundle_event: Event,
766 pub link_signer: Keys,
767 pub token: [u8; super::derive::TOKEN_LEN],
768 pub expires_at_ms: Option<u64>,
773 pub label: Option<String>,
774}
775
776pub async fn mint_public_link<T: Transport + ?Sized>(
782 transport: &T,
783 community: &CommunityV2,
784 base: &str,
785 expires_at_ms: Option<u64>,
786 label: Option<String>,
787) -> Result<MintedLink, String> {
788 let session = SessionGuard::capture();
789 let mut token = [0u8; super::derive::TOKEN_LEN];
790 token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
791 let link_signer = Keys::generate();
792 let bundle = bundle_of(community, Some(me_pk()?), expires_at_ms, label.clone());
793 let bundle_key = super::derive::invite_bundle_key(&token);
794 let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
795 let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
796
797 if !session.is_valid() {
798 return Err("account changed before minting link".to_string());
799 }
800 transport.publish_durable(&bundle_event, &community.relays).await?;
801 let minted = MintedLink { url, bundle_event, link_signer, token, expires_at_ms, label: label.clone() };
802 let _ = record_minted_link(transport, community, &minted).await;
806 if session.is_valid() {
811 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
812 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
813 let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
814 }
815 Ok(minted)
816}
817
818async fn fetch_invite_list<T: Transport + ?Sized>(
827 transport: &T,
828 relays: &[String],
829) -> Result<Option<invite::InviteList>, String> {
830 let signer = crate::signer::active_signer()?;
831 let my_pk = me_pk()?;
832 let query = Query {
833 kinds: vec![super::kind::INVITE_LIST],
834 authors: vec![my_pk.to_hex()],
835 limit: Some(4),
836 evidence: crate::community::transport::Evidence::Full,
837 ..Default::default()
838 };
839 let events = transport.fetch(&query, relays).await?;
840 let mut best: Option<(u64, invite::InviteList)> = None;
841 for e in events {
842 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
843 let at = e.created_at.as_secs();
844 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
845 best = Some((at, l));
846 }
847 }
848 }
849 Ok(best.map(|(_, l)| l))
850}
851
852fn live_signers_for(list: &invite::InviteList, community_id_hex: &str, now_ms: u64) -> Vec<PublicKey> {
860 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
861 list.entries
862 .iter()
863 .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
864 .filter(|e| !e.expires_at.is_some_and(|exp| now_ms > exp))
865 .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
866 .collect()
867}
868
869async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
873 let my_pk = me_pk()?;
874 let eid = super::derive::invite_links_locator(community.id(), &my_pk.to_bytes());
875 let content = invite::build_registry_content(live_signers);
876 publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content).await
877}
878
879async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
882 let session = SessionGuard::capture();
883 let signer = crate::signer::active_signer()?;
884 let my_pk = me_pk()?;
885 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
886 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
887 let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
892 if !list.entries.iter().any(|e| e.token == token_hex) {
893 list.entries.push(invite::InviteEntry {
894 token: token_hex,
895 signer_sk: minted.link_signer.secret_key().to_secret_hex(),
896 community_id: cid_hex.clone(),
897 url: minted.url.clone(),
898 label: minted.label.clone(),
899 created_at: now_ms() / 1000,
900 expires_at: minted.expires_at_ms,
901 extra: Default::default(),
902 });
903 }
904 if !session.is_valid() {
905 return Err("account changed during link record".to_string());
906 }
907 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
908 transport.publish(&event, &community.relays).await?;
909 let signers = live_signers_for(&list, &cid_hex, now_ms());
910 publish_invite_registry(transport, community, &session, &signers).await
911}
912
913pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
919 let session = SessionGuard::capture();
920 let signer = crate::signer::active_signer()?;
921 let my_pk = me_pk()?;
922 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
923 let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
924 let entry = list
925 .entries
926 .iter()
927 .find(|e| e.token == token_hex && e.community_id == cid_hex)
928 .cloned()
929 .ok_or("no such link in the invite list")?;
930 let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
932 let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
933 if !session.is_valid() {
934 return Err("account changed during revoke".to_string());
935 }
936 transport.publish_durable(&revocation, &community.relays).await?;
937 list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
939 list.entries.retain(|e| e.token != token_hex);
940 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
941 transport.publish(&event, &community.relays).await?;
942 let signers = live_signers_for(&list, &cid_hex, now_ms());
943 publish_invite_registry(transport, community, &session, &signers).await?;
944 if session.is_valid() {
946 let _ = crate::db::community::delete_public_invite(token_hex);
947 }
948 Ok(())
949}
950
951pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
956 let session = SessionGuard::capture();
957 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
958 let signer = crate::signer::active_signer()?;
963 let my_pk = me_pk()?;
964 let query = Query {
965 kinds: vec![super::kind::INVITE_LIST],
966 authors: vec![my_pk.to_hex()],
967 limit: Some(4),
968 ..Default::default()
969 };
970 let events = transport.fetch(&query, &community.relays).await?;
971 let mut best: Option<(u64, invite::InviteList)> = None;
972 for e in events {
973 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
974 let at = e.created_at.as_secs();
975 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
976 best = Some((at, l));
977 }
978 }
979 }
980 let Some((_, list)) = best else {
981 return Ok(());
982 };
983 let creator = my_pk;
984 let now = now_ms();
985 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
986 for entry in &list.entries {
987 if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
988 continue;
989 }
990 if entry.expires_at.is_some_and(|exp| now > exp) {
993 continue;
994 }
995 let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
996 let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
997 let bundle = bundle_of(community, Some(creator), entry.expires_at, entry.label.clone());
998 let bundle_key = super::derive::invite_bundle_key(&token);
999 if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
1000 if !session.is_valid() {
1001 return Err("account changed during link refresh".to_string());
1002 }
1003 let _ = transport.publish_durable(&event, &community.relays).await;
1004 }
1005 }
1006 let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
1016 if !mine_here {
1017 return Ok(());
1018 }
1019 let signers = live_signers_for(&list, &cid_hex, now);
1020 if !session.is_valid() {
1021 return Err("account changed during link refresh".to_string());
1022 }
1023 let _ = publish_invite_registry(transport, community, &session, &signers).await;
1024 Ok(())
1025}
1026
1027pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1032 use crate::community::roles::Permissions;
1033 use std::collections::BTreeMap;
1034 let Ok(owner) = community.owner() else { return false };
1035 let owner_hex = owner.to_hex();
1036 let cid = community.id();
1037 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1038 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1039 .unwrap_or_default()
1040 .into_iter()
1041 .filter(|(_, f)| f.0 == community.root_epoch.0)
1042 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1043 .collect();
1044 let control = control_group_key(&community.community_root, cid, community.root_epoch);
1045 let mut editions: Vec<ParsedEdition> = Vec::new();
1052 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1053 let mut oldest: Option<u64> = None;
1054 let mut until: Option<u64> = None;
1055 for page in 0..COMPACT_MAX_PAGES {
1056 let query = Query {
1060 kinds: vec![stream::KIND_WRAP],
1061 authors: vec![control.pk_hex()],
1062 until,
1063 limit: Some(FOLLOW_PAGE),
1064 evidence: crate::community::transport::Evidence::Quorum,
1065 ..Default::default()
1066 };
1067 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return true };
1068 let mut fresh = 0usize;
1069 for w in &wraps {
1070 if !seen_wraps.insert(w.id) {
1071 continue;
1072 }
1073 fresh += 1;
1074 let at = w.created_at.as_secs();
1075 if oldest.is_none_or(|o| at < o) {
1076 oldest = Some(at);
1077 }
1078 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1079 editions.push(ed);
1080 }
1081 }
1082 if fresh == 0 {
1083 if wraps.len() >= FOLLOW_PAGE {
1084 return true; }
1086 break;
1087 }
1088 until = oldest;
1089 if page + 1 == COMPACT_MAX_PAGES {
1090 return true;
1091 }
1092 }
1093 let authority = fold_authority(community, &editions, &floors);
1094
1095 let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
1096 for e in &editions {
1097 if e.vsk == vsk::INVITE_LINKS {
1098 by_eid.entry(e.entity_id).or_default().push(e);
1099 }
1100 }
1101 for (eid, group) in &by_eid {
1102 let authed: Vec<&ParsedEdition> = group
1108 .iter()
1109 .copied()
1110 .filter(|p| {
1111 let author = p.author.to_hex();
1112 !authority.banned.contains(&author)
1114 && authority.roles.is_authorized(&author, Some(&owner_hex), Permissions::CREATE_INVITE)
1115 && super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
1116 })
1117 .collect();
1118 if authed.is_empty() {
1119 continue;
1120 }
1121 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
1122 let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
1123 if invite::parse_registry_content(&authed[hi].content).map(|s| !s.is_empty()).unwrap_or(false) {
1124 return true;
1125 }
1126 }
1127 false
1128}
1129
1130async fn accept_bundle<T: Transport + ?Sized>(
1136 transport: &T,
1137 session: &SessionGuard,
1138 bundle: &CommunityInvite,
1139 invited_by: Option<PublicKey>,
1140 announce_join: bool,
1141) -> Result<CommunityV2, String> {
1142 let signer = crate::signer::active_signer()?;
1143 let my_pk = me_pk()?;
1144 let at_ms = now_ms();
1145 if bundle.expired(at_ms) {
1147 return Err("this invite has expired".to_string());
1148 }
1149 let community = CommunityV2::from_bundle(bundle, at_ms)?;
1151 let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
1154
1155 let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
1164 v.session.is_valid()
1165 && v.at.elapsed() < VERIFIED_PREVIEW_TTL
1166 && v.community_id == community.id().0
1167 && v.community_root == community.community_root
1168 });
1169 let (community, join_heads, join_banlist) = match handoff {
1170 Some(v) => {
1171 let mut c = v.folded;
1172 c.created_at_ms = at_ms;
1174 (c, v.heads, v.banned)
1175 }
1176 None => verify_owner_root_and_reconcile(transport, community).await?,
1177 };
1178
1179 if is_dissolved(transport, &community).await {
1181 return Err("this community has been dissolved".to_string());
1182 }
1183
1184 if join_banlist.contains(&my_pk.to_hex()) {
1189 return Err("you are banned from this community".to_string());
1190 }
1191
1192 if !session.is_valid() {
1196 return Err("account changed during join".to_string());
1197 }
1198 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1203 for h in &join_heads {
1204 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)?;
1205 }
1206 crate::db::community::save_community_v2(&community)?;
1207 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
1210 for ch in &community.channels {
1213 if let (true, Some(key)) = (ch.private, ch.key) {
1214 let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
1215 }
1216 }
1217
1218 if announce_join && !already_held {
1224 let attribution = invited_by
1225 .map(|p| p.to_hex())
1226 .or_else(|| bundle.creator_npub.clone())
1227 .zip(Some(bundle.label.clone().unwrap_or_default()));
1228 let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
1229 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1230 let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
1231 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1232 let _ = transport.publish(&join_wrap, &community.relays).await;
1233 }
1234 }
1235
1236 match republish_community_list(transport, Some(community.id())).await {
1240 Ok(true) => {}
1241 Ok(false) => republish_community_list_durable(Some(*community.id())),
1242 Err(e) => {
1243 crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
1244 republish_community_list_durable(Some(*community.id()));
1245 }
1246 }
1247 Ok(community)
1248}
1249
1250async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
1267 transport: &T,
1268 community: CommunityV2,
1269) -> Result<(CommunityV2, Vec<FoldedHead>, std::collections::BTreeSet<String>), String> {
1270 let owner = community.owner()?;
1271 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1272 let control_pk = control.pk_hex();
1273
1274 super::streamauth::prime(&community);
1281
1282 const PAGE: usize = 500;
1303 const MAX_PAGES: usize = 4;
1304 const FAR_FUTURE_SECS: u64 = 4_102_444_800; let mut editions: Vec<ParsedEdition> = Vec::new();
1306 let mut all_editions: Vec<ParsedEdition> = Vec::new();
1307 let mut found_genesis = false;
1308 let mut compacted_metadata = false;
1316 crate::log_debug!(
1317 "[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
1318 &control_pk[..12], community.root_epoch, community.relays
1319 );
1320 let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
1321 found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
1322 };
1323 for attempt in 0..2 {
1324 editions.clear();
1325 all_editions.clear();
1326 compacted_metadata = false;
1327 let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1328 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1329 for page_no in 0..MAX_PAGES {
1330 let query = Query {
1331 kinds: vec![stream::KIND_WRAP],
1332 authors: vec![control_pk.clone()],
1333 until,
1334 limit: Some(PAGE),
1335 evidence: crate::community::transport::Evidence::Full,
1336 ..Default::default()
1337 };
1338 let wraps = transport.fetch(&query, &community.relays).await?;
1339 crate::log_trace!(
1340 "[JoinVerify] attempt {} page {}: fetched {} wraps",
1341 attempt, page_no, wraps.len()
1342 );
1343 let mut oldest = u64::MAX;
1347 let mut fresh = 0usize;
1348 for w in &wraps {
1349 if !seen_wraps.insert(w.id) {
1350 continue;
1351 }
1352 fresh += 1;
1353 oldest = oldest.min(w.created_at.as_secs());
1354 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1355 crate::log_trace!(
1356 "[JoinVerify] edition vsk={} eid={} owner={} at={}",
1357 ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
1358 ed.author == owner, w.created_at.as_secs()
1359 );
1360 if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1361 if ed.author == owner {
1362 found_genesis = true;
1363 } else {
1364 compacted_metadata = true;
1365 }
1366 }
1367 if ed.author == owner {
1368 editions.push(ed.clone());
1369 }
1370 all_editions.push(ed);
1373 }
1374 }
1375 crate::log_debug!(
1376 "[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
1377 attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
1378 );
1379 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
1380 break; }
1382 until = Some(oldest);
1383 }
1384 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1385 break;
1386 }
1387 if attempt == 0 {
1388 if let Some(client) = crate::state::nostr_client() {
1395 super::streamauth::prime_auth(&client, &community.relays).await;
1396 }
1397 }
1398 }
1399 if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1400 return Err(
1401 "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"
1402 .to_string(),
1403 );
1404 }
1405 let empty_floors = Floors::new();
1414 let authority = AuthoritySet::owner_only();
1415 let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1416 let join_banlist = fold_authority(&community, &all_editions, &empty_floors).banned;
1421 Ok((fold.updated.unwrap_or(community), fold.heads, join_banlist))
1422}
1423
1424pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1428 let session = SessionGuard::capture();
1429 let signer = crate::signer::active_signer()?;
1430 let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
1431 accept_bundle(transport, &session, &bundle, Some(inviter), true).await
1432}
1433
1434pub async fn accept_parked_invite<T: Transport + ?Sized>(
1440 transport: &T,
1441 bundle_json: &str,
1442 inviter_hex: Option<&str>,
1443) -> Result<CommunityV2, String> {
1444 let session = SessionGuard::capture();
1445 let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1446 let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1447 accept_bundle(transport, &session, &bundle, invited_by, true).await
1448}
1449
1450pub async fn accept_migration_material<T: Transport + ?Sized>(
1456 transport: &T,
1457 jm: &super::list::JoinMaterial,
1458) -> Result<CommunityV2, String> {
1459 let session = SessionGuard::capture();
1460 let bundle = material_to_invite(jm);
1461 accept_bundle(transport, &session, &bundle, None, true).await
1462}
1463
1464pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1471 let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1472 let query = Query {
1477 kinds: vec![super::kind::INVITE_BUNDLE],
1478 authors: vec![parsed.link_signer.to_hex()],
1479 ..Default::default()
1480 };
1481 let relays = if parsed.bootstrap_relays.is_empty() {
1482 invite::stock_relays()
1483 } else {
1484 parsed.bootstrap_relays.clone()
1485 };
1486 let events = match transport.fetch(&query, &relays).await {
1491 Ok(evs) => evs,
1492 Err(_) => {
1493 wait_for_bootstrap_relay(&relays).await;
1494 transport.fetch(&query, &relays).await?
1495 }
1496 };
1497 let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1498
1499 let mut newest_live: Option<(u64, CommunityInvite)> = None;
1501 for event in &events {
1502 match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1503 Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1504 Ok(invite::BundleState::Live(bundle)) => {
1505 let at = event.created_at.as_secs();
1506 if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1507 newest_live = Some((at, *bundle));
1508 }
1509 }
1510 Err(_) => {} }
1512 }
1513 newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1514}
1515
1516async fn wait_for_bootstrap_relay(relays: &[String]) {
1520 let Some(client) = crate::state::nostr_client() else { return };
1521 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8);
1522 loop {
1523 for url in relays {
1524 if let Ok(Some(relay)) = client.relay(url).await {
1525 if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
1526 return;
1527 }
1528 }
1529 }
1530 if tokio::time::Instant::now() >= deadline {
1531 return;
1532 }
1533 tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1534 }
1535}
1536
1537struct VerifiedPreview {
1543 session: SessionGuard,
1544 at: std::time::Instant,
1545 community_id: [u8; 32],
1546 community_root: [u8; 32],
1547 folded: CommunityV2,
1548 heads: Vec<FoldedHead>,
1549 banned: std::collections::BTreeSet<String>,
1552}
1553static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
1554const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
1555
1556pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1563 let bundle = fetch_public_bundle(transport, url).await?;
1564 preview_bundle(transport, &bundle).await
1565}
1566
1567pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
1572 let community = CommunityV2::from_bundle(bundle, 0)?;
1573 match verify_owner_root_and_reconcile(transport, community.clone()).await {
1574 Ok((folded, heads, banned)) => {
1575 *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
1576 session: SessionGuard::capture(),
1577 at: std::time::Instant::now(),
1578 community_id: folded.id().0,
1579 community_root: folded.community_root,
1580 folded: folded.clone(),
1581 heads,
1582 banned,
1583 });
1584 Ok(folded)
1585 }
1586 Err(_) => Ok(community),
1587 }
1588}
1589
1590pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1592 let session = SessionGuard::capture();
1594 let bundle = fetch_public_bundle(transport, url).await?;
1595 if !session.is_valid() {
1596 return Err("account changed during join".to_string());
1597 }
1598 accept_bundle(transport, &session, &bundle, None, true).await
1599}
1600
1601pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1603 let session = SessionGuard::capture();
1604 let signer = crate::signer::active_signer()?;
1605 let my_pk = me_pk()?;
1606 let at_ms = now_ms();
1607 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1608 let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
1609 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1610 let _ = transport.publish(&wrap, &community.relays).await;
1611 }
1612 if !session.is_valid() {
1613 return Err("account changed during leave".to_string());
1614 }
1615 let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
1619 if !session.is_valid() {
1621 return Err("account changed during leave".to_string());
1622 }
1623 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1624 Ok(())
1625}
1626
1627pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1632 let session = SessionGuard::capture();
1633 assert_current_root(community)?;
1634 let signer = crate::signer::active_signer()?;
1635 let my_pk = me_pk()?;
1636 let authority = fetch_authority(transport, community).await;
1638 let owner_hex = community.owner()?.to_hex();
1639 if !authority.roles.can_act_on_member(
1640 &my_pk.to_hex(),
1641 Some(&owner_hex),
1642 &target.to_hex(),
1643 crate::community::roles::Permissions::KICK,
1644 ) {
1645 return Err("not authorized to kick this member".to_string());
1646 }
1647 let target_hex = target.to_hex();
1659 let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
1660 let may_strip = authority.roles.can_act_on_member(
1661 &my_pk.to_hex(),
1662 Some(&owner_hex),
1663 &target_hex,
1664 crate::community::roles::Permissions::MANAGE_ROLES,
1665 );
1666 if holds_roles && may_strip {
1667 grant_roles(transport, community, target, Vec::new())
1668 .await
1669 .map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
1670 if !session.is_valid() {
1671 return Err("account changed during kick".to_string());
1672 }
1673 }
1674 let at_ms = now_ms();
1675 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1676 let citation = required_authority_citation(community, &my_pk)?;
1679 let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
1680 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
1681 .map_err(|e| e.to_string())?;
1682 if !session.is_valid() {
1683 return Err("account changed before send".to_string());
1684 }
1685 transport.publish(&wrap, &community.relays).await?;
1686 Ok(())
1687}
1688
1689pub struct AuthorityView {
1695 pub roles: crate::community::roles::CommunityRoles,
1696 pub banned: std::collections::BTreeSet<String>,
1697 pub gapped: bool,
1699 pub floored: std::collections::BTreeSet<String>,
1701 pub head_entities: std::collections::BTreeSet<String>,
1703 pub banned_at: std::collections::BTreeMap<String, u64>,
1705}
1706
1707pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1713 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1714 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1715 .unwrap_or_default()
1716 .into_iter()
1717 .filter(|(_, f)| f.0 == community.root_epoch.0)
1718 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1719 .collect();
1720 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1721
1722 let mut editions: Vec<ParsedEdition> = Vec::new();
1723 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1724 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1725 let mut oldest: Option<u64> = None;
1726 let mut until: Option<u64> = None;
1727 let mut a = fold_authority(community, &[], &floors);
1732 for _ in 0..FOLLOW_MAX_PAGES {
1733 let query = Query {
1737 kinds: vec![stream::KIND_WRAP],
1738 authors: vec![control.pk_hex()],
1739 until,
1740 limit: Some(FOLLOW_PAGE),
1741 evidence: crate::community::transport::Evidence::Quorum,
1742 ..Default::default()
1743 };
1744 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
1745 let mut fresh = 0usize;
1746 for w in &wraps {
1747 if !seen_wraps.insert(w.id) {
1748 continue;
1749 }
1750 fresh += 1;
1751 let at = w.created_at.as_secs();
1752 if oldest.is_none_or(|o| at < o) {
1753 oldest = Some(at);
1754 }
1755 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1756 if seen.insert(ed.inner_id) {
1757 editions.push(ed);
1758 }
1759 }
1760 }
1761 a = fold_authority(community, &editions, &floors);
1762 if !a.gapped || fresh == 0 {
1763 break;
1764 }
1765 until = oldest;
1766 }
1767 AuthorityView {
1768 roles: a.roles,
1769 banned: a.banned,
1770 gapped: a.gapped,
1771 floored: floors.keys().cloned().collect(),
1772 head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
1773 banned_at: a.banned_at,
1774 }
1775}
1776
1777async fn fetch_guestbook_events<T: Transport + ?Sized>(
1789 transport: &T,
1790 community: &CommunityV2,
1791 since_secs: u64,
1792) -> Result<(Vec<guestbook::GuestbookEvent>, u64), String> {
1793 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1794 const GB_PAGE: usize = 500;
1795 const GB_MAX_PAGES: usize = 12;
1796 let mut events = Vec::new();
1797 let mut newest: u64 = since_secs;
1798 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1799 let mut until: Option<u64> = None;
1800 let mut oldest: Option<u64> = None;
1801 for _ in 0..GB_MAX_PAGES {
1802 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() };
1805 let wraps = transport.fetch(&query, &community.relays).await?;
1806 let mut fresh = 0usize;
1807 for wrap in &wraps {
1808 if !seen.insert(wrap.id) {
1809 continue;
1810 }
1811 fresh += 1;
1812 let at = wrap.created_at.as_secs();
1813 if oldest.is_none_or(|o| at < o) {
1814 oldest = Some(at);
1815 }
1816 if at > newest {
1817 newest = at;
1818 }
1819 if at < since_secs {
1821 continue;
1822 }
1823 if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
1824 if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
1825 events.push(ev);
1826 }
1827 }
1828 }
1829 if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
1830 break;
1831 }
1832 match oldest {
1833 Some(o) if o > 0 => until = Some(o),
1834 _ => break,
1835 }
1836 }
1837 Ok((events, newest))
1838}
1839
1840fn fold_members(
1845 community: &CommunityV2,
1846 events: &[guestbook::GuestbookEvent],
1847 mut observed: std::collections::BTreeMap<PublicKey, u64>,
1848 roles: &crate::community::roles::CommunityRoles,
1849 banlist: &std::collections::BTreeSet<PublicKey>,
1850 banned_at: &std::collections::BTreeMap<PublicKey, u64>,
1851) -> Result<Vec<PublicKey>, String> {
1852 let owner = community.owner()?;
1853 let owner_hex = owner.to_hex();
1854 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1855
1856 for g in &roles.grants {
1863 if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
1864 observed.entry(pk).or_insert(0);
1865 }
1866 }
1867
1868 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1877 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
1881 let actor_hex = actor.to_hex();
1882 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
1883 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1884 };
1885 let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
1886 let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at);
1887 if !banlist.contains(&owner) {
1889 members.insert(owner);
1890 }
1891 Ok(members.into_iter().collect())
1892}
1893
1894pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
1904 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1905 let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
1906 return false;
1907 };
1908 let Ok(owner) = community.owner() else { return false };
1909 let owner_hex = owner.to_hex();
1910 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
1911 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1912 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
1913 let actor_hex = actor.to_hex();
1914 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
1915 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1916 };
1917 matches!(
1918 guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
1919 Some(st) if st.verdict == guestbook::Verdict::Kicked
1920 )
1921}
1922
1923pub async fn sync_guestbook<T: Transport + ?Sized>(
1928 transport: &T,
1929 community: &CommunityV2,
1930 session: &SessionGuard,
1931) -> Result<Vec<guestbook::GuestbookEvent>, String> {
1932 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1933 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1934 let since = cursor.saturating_sub(1);
1937 let (fresh, newest) = fetch_guestbook_events(transport, community, since).await?;
1938 if !session.is_valid() {
1939 return Err("account changed during guestbook sync".to_string());
1940 }
1941 let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
1942 let mut added = Vec::new();
1943 for ev in fresh {
1944 if !known.contains(&ev.rumor_id) {
1945 events.push(ev.clone());
1946 added.push(ev);
1947 }
1948 }
1949 if !added.is_empty() || newest > cursor {
1950 crate::db::community::set_guestbook(&cid_hex, &events, newest.max(cursor))?;
1951 }
1952 Ok(added)
1953}
1954
1955pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
1958 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1959 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1960 if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
1961 return Ok(false);
1962 }
1963 events.push(ev);
1964 crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
1965 Ok(true)
1966}
1967
1968pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
1975 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1976 let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1977 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
1978 for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
1979 if let Ok(pk) = PublicKey::parse(&npub) {
1980 observed.insert(pk, last_active_secs.saturating_mul(1000));
1981 }
1982 }
1983 let roles = crate::db::community::get_community_roles(&cid_hex)?;
1984 let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
1985 .unwrap_or_default()
1986 .iter()
1987 .filter_map(|h| PublicKey::from_hex(h).ok())
1988 .collect();
1989 let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
1992 .unwrap_or_default()
1993 .into_iter()
1994 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
1995 .collect();
1996 fold_members(community, &events, observed, &roles, &banlist, &banned_at)
1997}
1998
1999pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2005 let (events, _newest) = fetch_guestbook_events(transport, community, 0).await?;
2006 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2009 for ch in &community.channels {
2010 if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
2011 for f in &page {
2012 let e = observed.entry(f.event.opened().author).or_insert(0);
2013 *e = (*e).max(f.event.opened().at_ms);
2014 }
2015 }
2016 }
2017
2018 let authority = fetch_authority(transport, community).await;
2023 let banlist: std::collections::BTreeSet<PublicKey> =
2025 authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
2026 let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
2030 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
2031 )
2032 .unwrap_or_default()
2033 .into_iter()
2034 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2035 .collect();
2036 for (h, at) in &authority.banned_at {
2037 if let Ok(pk) = PublicKey::from_hex(h) {
2038 let slot = banned_at.entry(pk).or_insert(0);
2039 *slot = (*slot).max(*at);
2040 }
2041 }
2042 fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
2043}
2044
2045pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2053 let session = SessionGuard::capture();
2054 let signer = crate::signer::active_signer()?;
2055 let my_pk = me_pk()?;
2056 if community.owner()? != my_pk {
2057 return Err("only the owner can dissolve a community".to_string());
2058 }
2059 let at = now_ms() / 1000;
2060 let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
2061 let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
2062 if !session.is_valid() {
2063 return Err("account changed during dissolve".to_string());
2064 }
2065 transport.publish_durable(&wrap, &community.relays).await?;
2067 crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2068 Ok(())
2069}
2070
2071pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
2076 let group = super::derive::dissolved_group_key(community.id());
2077 let query = Query {
2078 kinds: vec![stream::KIND_WRAP],
2079 authors: vec![group.pk_hex()],
2080 limit: Some(20),
2081 ..Default::default()
2082 };
2083 let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
2084 return false;
2085 };
2086 wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
2087}
2088
2089pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
2102 let session = SessionGuard::capture();
2103 let cid = community.id();
2104 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2105 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2107 return Err("this community has been dissolved; it cannot be re-founded".to_string());
2108 }
2109 let signer = crate::signer::active_signer()?;
2110 let my_pk = me_pk()?;
2111 let lock = super::realtime::follow_lock(cid);
2115 let _guard = lock.lock().await;
2116 let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
2120 let community = &fresh;
2121 let owner = community.owner()?;
2122
2123 {
2130 let owner_hex = owner.to_hex();
2131 let me_hex = my_pk.to_hex();
2132 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2138 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2139 let authorized = my_pk == owner
2140 || (!banned.contains(&me_hex)
2141 && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
2142 && removed.iter().all(|t| {
2143 roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
2144 }));
2145 if !authorized {
2146 return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
2147 }
2148 }
2149
2150 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2154 .into_iter()
2155 .filter(|(_, f)| f.0 == community.root_epoch.0)
2156 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2157 .collect();
2158 let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2159 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2166 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2167 let mut oldest: Option<u64> = None;
2168 let mut until: Option<u64> = None;
2169 let mut truncated = false;
2173 for page in 0..COMPACT_MAX_PAGES {
2174 let query = Query {
2178 kinds: vec![stream::KIND_WRAP],
2179 authors: vec![current_control.pk_hex()],
2180 until,
2181 limit: Some(FOLLOW_PAGE),
2182 evidence: crate::community::transport::Evidence::Full,
2183 ..Default::default()
2184 };
2185 let wraps = transport.fetch(&query, &community.relays).await?;
2186 let mut fresh = 0usize;
2187 for w in &wraps {
2188 if !seen_wraps.insert(w.id) {
2189 continue;
2190 }
2191 fresh += 1;
2192 let at = w.created_at.as_secs();
2193 if oldest.is_none_or(|o| at < o) {
2194 oldest = Some(at);
2195 }
2196 if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
2197 opened.push(parsed);
2198 }
2199 }
2200 if fresh == 0 {
2201 truncated = wraps.len() >= FOLLOW_PAGE;
2205 break;
2206 }
2207 until = oldest;
2208 if page + 1 == COMPACT_MAX_PAGES {
2209 truncated = true;
2210 }
2211 }
2212 if truncated {
2213 return Err(
2214 "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(),
2215 );
2216 }
2217
2218 let prev_epoch = community.root_epoch;
2219 let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
2220 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2221 if !session.is_valid() {
2227 return Err("account changed during re-founding compaction".to_string());
2228 }
2229 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2230 let new_control = control_group_key(&new_root, cid, new_epoch);
2231 let at = now_ms();
2232 let at_secs = at / 1000;
2233
2234 use std::collections::BTreeMap;
2240 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2241 for (i, (e, _)) in opened.iter().enumerate() {
2242 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2243 }
2244 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2245 for (floor_key, floor) in &floors {
2246 let head_idx = by_eid
2254 .get(floor_key)
2255 .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2256 let Some(head_idx) = head_idx else {
2257 return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
2258 };
2259 let (head_ed, head_os) = &opened[head_idx];
2260 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2261 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2262 carried.push((h, rewrapped));
2263 }
2264 if !session.is_valid() {
2265 return Err("account changed during re-founding acquire".to_string());
2266 }
2267
2268 let members = memberlist(transport, community).await?;
2270 let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
2271 let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
2272 if !recipients.iter().any(|p| *p == my_pk) {
2273 recipients.push(my_pk);
2274 }
2275
2276 let mut base_blobs = Vec::new();
2278 for r in &recipients {
2279 base_blobs.push(
2280 super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2281 .await
2282 .map_err(|e| e.to_string())?,
2283 );
2284 }
2285 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2286 let base_chunks =
2287 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())
2288 .await
2289 .map_err(|e| e.to_string())?;
2290
2291 let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
2294 let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
2295 for ch in &community.channels {
2296 let (Some(old_key), true) = (ch.key, ch.private) else { continue };
2297 let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2298 if !session.is_valid() {
2302 return Err("account changed during re-founding channel prepare".to_string());
2303 }
2304 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)?;
2305 let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
2306 let mut ch_blobs = Vec::new();
2307 for r in &recipients {
2308 ch_blobs.push(
2309 super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
2310 .await
2311 .map_err(|e| e.to_string())?,
2312 );
2313 }
2314 let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
2315 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())
2316 .await
2317 .map_err(|e| e.to_string())?;
2318 channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
2319 channel_chunk_sets.push(ch_chunks);
2320 }
2321 if !session.is_valid() {
2322 return Err("account changed during re-founding prepare".to_string());
2323 }
2324
2325 for c in &base_chunks {
2328 transport.publish_durable(c, &community.relays).await?;
2329 }
2330 for set in &channel_chunk_sets {
2331 for c in set {
2332 transport.publish_durable(c, &community.relays).await?;
2333 }
2334 }
2335 for (_, wrap) in &carried {
2336 transport.publish_durable(wrap, &community.relays).await?;
2337 }
2338 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2341 let snap_id = crate::community::random_32();
2342 for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
2343 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
2344 let _ = transport.publish(&wrap, &community.relays).await;
2345 }
2346 }
2347
2348 if !session.is_valid() {
2350 return Err("account changed during re-founding commit".to_string());
2351 }
2352 if crate::db::community::community_protocol(cid)?.is_none() {
2353 return Ok(community.clone()); }
2355 let mut updated = community.clone();
2359 updated.community_root = new_root;
2360 updated.root_epoch = new_epoch;
2361 for (id, key, ep) in &channel_updates {
2362 if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
2363 c.key = Some(*key);
2364 c.epoch = *ep;
2365 }
2366 }
2367 crate::db::community::save_community_v2(&updated)?;
2368 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2373 for (h, _) in &carried {
2374 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2375 }
2376 if let Some(client) = crate::state::nostr_client() {
2382 super::realtime::refresh_subscription(&client).await;
2383 }
2384 for attempt in 0..3u8 {
2390 match refresh_public_links(transport, &updated).await {
2391 Ok(()) => break,
2392 Err(_) if !session.is_valid() => break, Err(e) if attempt == 2 => {
2394 crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
2395 }
2396 Err(_) => continue,
2397 }
2398 }
2399 Ok(updated)
2400}
2401
2402pub async fn refound_at_birth<T: Transport + ?Sized>(
2418 transport: &T,
2419 community: &CommunityV2,
2420 snapshot_members: &[PublicKey],
2421) -> Result<CommunityV2, String> {
2422 let session = SessionGuard::capture();
2423 let cid = community.id();
2424 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2425 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2428 return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
2429 }
2430 let signer = crate::signer::active_signer()?;
2431 let my_pk = me_pk()?;
2432 if my_pk != community.owner()? {
2433 return Err("only the owner can birth-refound the migration twin".to_string());
2434 }
2435 let lock = super::realtime::follow_lock(cid);
2436 let _guard = lock.lock().await;
2437 let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
2438 if community.root_epoch.0 == 1 {
2444 return Ok(community);
2445 }
2446 if community.root_epoch.0 != 0 {
2447 return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
2448 }
2449 let community = &community;
2450
2451 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2455 .into_iter()
2456 .filter(|(_, f)| f.0 == community.root_epoch.0)
2457 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2458 .collect();
2459 let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2460 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2461 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2462 let mut oldest: Option<u64> = None;
2463 let mut until: Option<u64> = None;
2464 let mut truncated = false;
2466 for page in 0..COMPACT_MAX_PAGES {
2467 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() };
2468 let wraps = transport.fetch(&query, &community.relays).await?;
2469 let mut fresh = 0usize;
2470 for w in &wraps {
2471 if !seen_wraps.insert(w.id) { continue; }
2472 fresh += 1;
2473 let at = w.created_at.as_secs();
2474 if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
2475 if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
2476 opened.push(parsed);
2477 }
2478 }
2479 if fresh == 0 {
2480 truncated = wraps.len() >= FOLLOW_PAGE;
2481 break;
2482 }
2483 until = oldest;
2484 if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
2485 }
2486 if truncated {
2487 return Err(
2488 "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(),
2489 );
2490 }
2491
2492 let prev_epoch = community.root_epoch; let new_epoch = Epoch(1);
2494 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2495 if !session.is_valid() {
2496 return Err("account changed during birth-refound compaction".to_string());
2497 }
2498 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2499 let new_control = control_group_key(&new_root, cid, new_epoch);
2500 let at = now_ms();
2501 let at_secs = at / 1000;
2502
2503 use std::collections::BTreeMap;
2504 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2505 for (i, (e, _)) in opened.iter().enumerate() {
2506 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2507 }
2508 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2509 for (floor_key, floor) in &floors {
2510 let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2511 let Some(head_idx) = head_idx else {
2512 return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
2513 };
2514 let (head_ed, head_os) = &opened[head_idx];
2515 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2516 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2517 carried.push((h, rewrapped));
2518 }
2519 if !session.is_valid() {
2520 return Err("account changed during birth-refound acquire".to_string());
2521 }
2522
2523 let base_blobs = vec![
2525 super::rekey::build_blob(&signer, &my_pk.to_bytes(), &my_pk, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2526 .await
2527 .map_err(|e| e.to_string())?,
2528 ];
2529 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2530 let base_chunks =
2531 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())
2532 .await
2533 .map_err(|e| e.to_string())?;
2534 if !session.is_valid() {
2535 return Err("account changed during birth-refound prepare".to_string());
2536 }
2537
2538 for c in &base_chunks {
2540 transport.publish_durable(c, &community.relays).await?;
2541 }
2542 for (_, wrap) in &carried {
2543 transport.publish_durable(wrap, &community.relays).await?;
2544 }
2545 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2553 let snap_id = crate::community::random_32();
2554 let snapshot_wraps: Vec<Event> = {
2555 let mut out = Vec::new();
2556 for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
2557 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
2558 .await
2559 .map_err(|e| format!("seal birth snapshot: {e}"))?;
2560 out.push(wrap);
2561 }
2562 out
2563 };
2564 for wrap in &snapshot_wraps {
2565 transport.publish_durable(wrap, &community.relays).await?;
2566 }
2567 let verify_view = {
2576 let mut v = community.clone();
2577 v.community_root = new_root;
2578 v.root_epoch = new_epoch;
2579 v
2580 };
2581 let expected: Vec<PublicKey> = {
2582 let banlist = fetch_authority(transport, &verify_view).await.banned;
2583 snapshot_members.iter().copied()
2584 .filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
2585 .collect()
2586 };
2587 if !expected.is_empty() {
2588 let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
2589 let missing = expected.iter().filter(|m| !folded.contains(m)).count();
2590 if missing > 0 {
2591 return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
2592 }
2593 }
2594
2595 if !session.is_valid() {
2597 return Err("account changed during birth-refound commit".to_string());
2598 }
2599 if crate::db::community::community_protocol(cid)?.is_none() {
2600 return Ok(community.clone());
2601 }
2602 let mut updated = community.clone();
2603 updated.community_root = new_root;
2604 updated.root_epoch = new_epoch;
2605 crate::db::community::save_community_v2(&updated)?;
2606 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2607 for (h, _) in &carried {
2608 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2609 }
2610 Ok(updated)
2611}
2612
2613fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
2620 if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
2621 return Ok(existing);
2622 }
2623 let fresh = crate::community::random_32();
2624 crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
2625 Ok(fresh)
2626}
2627
2628fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
2634 let hex = crate::simd::hex::bytes_to_hex_32;
2635 let channels = community
2636 .channels
2637 .iter()
2638 .filter(|c| c.private)
2639 .filter_map(|c| {
2640 c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: hex(&k), epoch: c.epoch.0, name: c.name.clone() })
2641 })
2642 .collect();
2643 super::list::JoinMaterial {
2644 community_id: hex(&community.identity.community_id.0),
2645 owner: hex(&community.identity.owner_xonly),
2646 owner_salt: hex(&community.identity.owner_salt),
2647 community_root: hex(&community.community_root),
2648 root_epoch: community.root_epoch.0,
2649 channels,
2650 relays: community.relays.clone(),
2651 name: community.name.clone(),
2652 extra: Default::default(),
2653 }
2654}
2655
2656fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
2660 let channels = jm
2661 .channels
2662 .iter()
2663 .map(|c| invite::ChannelGrant { id: c.id.clone(), key: c.key.clone(), epoch: c.epoch, name: c.name.clone() })
2664 .collect();
2665 CommunityInvite {
2666 community_id: jm.community_id.clone(),
2667 owner: jm.owner.clone(),
2668 owner_salt: jm.owner_salt.clone(),
2669 community_root: jm.community_root.clone(),
2670 root_epoch: jm.root_epoch,
2671 channels,
2672 relays: jm.relays.clone(),
2673 name: jm.name.clone(),
2674 icon: None,
2675 expires_at: None,
2676 creator_npub: None,
2677 label: None,
2678 extra: Default::default(),
2679 }
2680}
2681
2682fn held_v2_relays() -> Vec<String> {
2685 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2686 if let Ok(ids) = crate::db::community::list_community_ids() {
2687 for id in ids {
2688 if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2689 if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
2690 set.extend(c.relays);
2691 }
2692 }
2693 }
2694 }
2695 set.into_iter().collect()
2696}
2697
2698async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
2704 let signer = crate::signer::active_signer()?;
2705 let my_pk = me_pk()?;
2706 let query = Query {
2707 kinds: vec![super::kind::COMMUNITY_LIST],
2708 authors: vec![my_pk.to_hex()],
2709 limit: Some(4),
2710 ..Default::default()
2711 };
2712 let events = transport.fetch(&query, relays).await?;
2713 let mut best: Option<(u64, super::list::CommunityList)> = None;
2714 for e in events {
2715 if let Ok(l) = super::list::parse_list_event_signed(&signer, my_pk, &e).await {
2716 let at = e.created_at.as_secs();
2717 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
2718 best = Some((at, l));
2719 }
2720 }
2721 }
2722 Ok(best.map(|(_, l)| l))
2723}
2724
2725pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
2737 let session = SessionGuard::capture();
2738 let signer = crate::signer::active_signer()?;
2739 let my_pk = me_pk()?;
2740 let relays = held_v2_relays();
2741 if relays.is_empty() {
2742 return Ok(false); }
2744 let remote = match fetch_community_list(transport, &relays).await {
2748 Ok(r) => r.unwrap_or_default(),
2749 Err(e) => {
2750 crate::log_warn!(
2756 "[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
2757 e,
2758 just_joined
2759 .map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
2760 .unwrap_or_default()
2761 );
2762 return Ok(false);
2763 }
2764 };
2765 let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
2766 let now = now_ms();
2767 let mut local = super::list::CommunityList::default();
2768 for id in crate::db::community::list_community_ids()? {
2769 if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2770 continue;
2771 }
2772 let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
2773 let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
2774 let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
2775 let tombstoned_at = remote
2787 .tombstones
2788 .iter()
2789 .find(|t| t.community_id == cid_hex)
2790 .map(|t| t.removed_at)
2791 .unwrap_or(0);
2792 let held_since = c.created_at_ms;
2793 if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
2794 crate::log_warn!(
2795 "[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
2796 &cid_hex[..8], tombstoned_at, held_since
2797 );
2798 continue;
2799 }
2800 let added_at = if remote.is_live(&cid_hex) && !is_join {
2805 remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
2806 } else if !is_join && tombstoned_at > 0 {
2807 held_since
2808 } else {
2809 now
2810 };
2811 let jm = join_material(&c);
2812 local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
2813 }
2814 let merged = remote.merge(&local);
2815 merged.assert_fits().map_err(|e| e.to_string())?;
2816 let event = super::list::build_list_event_signed(&signer, my_pk, &merged).await.map_err(|e| e.to_string())?;
2817 if !session.is_valid() {
2818 return Err("account changed during community-list publish".to_string());
2819 }
2820 if let Err(e) = transport.publish(&event, &relays).await {
2821 crate::log_warn!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
2822 return Err(e);
2823 }
2824 Ok(true)
2825}
2826
2827const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
2832
2833pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
2846 if crate::state::nostr_client().is_none() {
2847 return;
2848 }
2849 let session = SessionGuard::capture();
2850 tokio::spawn(async move {
2851 for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
2852 if !session.is_valid() {
2853 return;
2854 }
2855 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2856 match republish_community_list(&transport, just_joined.as_ref()).await {
2857 Ok(true) => {
2858 if attempt > 0 {
2859 crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
2860 }
2861 return;
2862 }
2863 Ok(false) => {} Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
2865 }
2866 tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
2867 }
2868 crate::log_warn!(
2869 "[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
2870 LIST_REPUBLISH_BACKOFF_SECS.len()
2871 );
2872 });
2873}
2874
2875async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
2878 let signer = crate::signer::active_signer()?;
2879 let my_pk = me_pk()?;
2880 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
2881 let mut doc = match fetch_community_list(transport, relays).await {
2885 Ok(d) => d.unwrap_or_default(),
2886 Err(e) => return Err(e),
2887 };
2888 let now = now_ms();
2889 doc.tombstones.retain(|t| t.community_id != cid_hex);
2890 doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
2891 doc.assert_fits().map_err(|e| e.to_string())?;
2892 let event = super::list::build_list_event_signed(&signer, my_pk, &doc).await.map_err(|e| e.to_string())?;
2893 transport.publish(&event, relays).await
2894}
2895
2896pub struct ListSyncOutcome {
2903 pub joined: Vec<CommunityV2>,
2905 pub removed: Vec<(String, Vec<String>)>,
2912}
2913
2914pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
2915 let session = SessionGuard::capture();
2916 let mut relays = held_v2_relays();
2917 relays.extend(bootstrap_relays.iter().cloned());
2918 relays.sort();
2919 relays.dedup();
2920 if relays.is_empty() {
2921 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
2922 }
2923 let list = match fetch_community_list(transport, &relays).await {
2924 Ok(Some(l)) => l,
2925 Ok(None) | Err(_) => return Ok(ListSyncOutcome { joined: vec![], removed: vec![] }),
2926 };
2927 let mut removed: Vec<(String, Vec<String>)> = Vec::new();
2933 for t in &list.tombstones {
2934 if list.is_live(&t.community_id) {
2935 continue;
2936 }
2937 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
2938 let id = crate::community::CommunityId(cid);
2939 let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
2940 continue; };
2942 if held.created_at_ms > t.removed_at {
2948 crate::log_warn!(
2949 "[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
2950 &t.community_id[..8], t.removed_at, held.created_at_ms
2951 );
2952 continue;
2953 }
2954 if !session.is_valid() {
2955 return Err("account changed during community-list sync".to_string());
2956 }
2957 let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
2958 let _ = crate::db::community::delete_community(&t.community_id);
2959 removed.push((t.community_id.clone(), channel_ids));
2960 }
2961 let mut joined = Vec::new();
2962 for entry in list.live_entries() {
2963 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
2964 if crate::db::community::load_community_v2(&crate::community::CommunityId(cid)).ok().flatten().is_some() {
2965 continue; }
2967 if !session.is_valid() {
2968 return Err("account changed during community-list sync".to_string());
2969 }
2970 let bundle = material_to_invite(&entry.current);
2975 if let Ok(community) = accept_bundle(transport, &session, &bundle, None, false).await {
2976 joined.push(community);
2977 }
2978 }
2979 Ok(ListSyncOutcome { joined, removed })
2980}
2981
2982pub(super) fn citation_is_synced(
3012 cid_hex: &str,
3013 owner_hex: &str,
3014 actor_hex: &str,
3015 citation: Option<&crate::community::edition::AuthorityCitation>,
3016) -> bool {
3017 if owner_hex == actor_hex {
3018 return true;
3019 }
3020 if citation.is_none() {
3021 return false;
3022 }
3023 let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
3024 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
3025 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
3026 &crate::community::CommunityId(cid_bytes),
3027 &actor_bytes,
3028 ));
3029 let head: Vec<crate::community::roster::EntityHead> =
3030 crate::db::community::get_edition_head(cid_hex, &grant_hex)
3031 .ok()
3032 .flatten()
3033 .map(|(version, self_hash)| crate::community::roster::EntityHead {
3034 entity_hex: grant_hex.clone(),
3035 version,
3036 self_hash,
3037 inner_id: [0u8; 32],
3038 citation: None,
3039 })
3040 .into_iter()
3041 .collect();
3042 crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
3043}
3044
3045fn required_authority_citation(
3053 community: &CommunityV2,
3054 actor: &PublicKey,
3055) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
3056 if community.owner().ok().as_ref() == Some(actor) {
3057 return Ok(None); }
3059 my_authority_citation(community, actor).map(Some).ok_or_else(|| {
3060 "your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
3061 })
3062}
3063
3064fn my_authority_citation(
3065 community: &CommunityV2,
3066 actor: &PublicKey,
3067) -> Option<crate::community::edition::AuthorityCitation> {
3068 if community.owner().ok().as_ref() == Some(actor) {
3069 return None;
3070 }
3071 let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
3072 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3073 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
3074 crate::db::community::get_edition_head(&cid_hex, &entity_hex)
3075 .ok()
3076 .flatten()
3077 .map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
3078}
3079
3080fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
3088 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3089 match crate::db::community::get_server_root_epoch(&cid_hex)? {
3090 Some(held) if held != community.root_epoch.0 => Err(format!(
3091 "the community re-founded mid-action (epoch {} -> {held}); retry",
3092 community.root_epoch.0
3093 )),
3094 _ => Ok(()), }
3096}
3097
3098async fn publish_control_edition<T: Transport + ?Sized>(
3099 transport: &T,
3100 community: &CommunityV2,
3101 session: &SessionGuard,
3102 vsk: &str,
3103 entity_id: &[u8; 32],
3104 content: &str,
3105) -> Result<(), String> {
3106 assert_current_root(community)?;
3107 let signer = crate::signer::active_signer()?;
3108 let my_pk = me_pk()?;
3109 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
3110 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3111 let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
3112 let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
3113 Some((v, h)) => (v + 1, Some(h)),
3114 None => (1, None),
3115 };
3116 let citation = required_authority_citation(community, &my_pk)?;
3122 let at = now_ms() / 1000;
3123 let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
3124 let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
3125 if !session.is_valid() {
3126 return Err("account changed before control publish".to_string());
3127 }
3128 transport.publish(&wrap, &community.relays).await?;
3129 if !session.is_valid() {
3135 return Ok(());
3136 }
3137 if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
3138 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
3139 }
3140 Ok(())
3141}
3142
3143pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
3147 let session = SessionGuard::capture();
3148 super::roles::validate_role(role)?;
3149 let content = super::roles::role_content_json(role)?;
3150 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
3151 publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content).await
3152}
3153
3154pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
3158 let session = SessionGuard::capture();
3159 let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
3160 let content = super::roles::grant_content_json(&grant)?;
3161 let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
3162 publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content).await
3163}
3164
3165pub async fn ensure_admin_role<T: Transport + ?Sized>(
3170 transport: &T,
3171 community: &CommunityV2,
3172 view: &AuthorityView,
3173 create_if_missing: bool,
3174) -> Result<Option<String>, String> {
3175 use crate::community::roles::{Permissions, Role, RoleScope};
3176 if let Some(r) = view
3177 .roles
3178 .roles
3179 .iter()
3180 .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_ALL))
3181 {
3182 return Ok(Some(r.role_id.clone()));
3183 }
3184 if !create_if_missing {
3185 return Ok(None);
3186 }
3187 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3188 let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
3189 set_role(transport, community, &Role::admin(role_id.clone())).await?;
3190 Ok(Some(role_id))
3191}
3192
3193pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3200 let session = SessionGuard::capture();
3203 let my_pk = me_pk()?;
3204 if my_pk != community.owner()? {
3205 return Err("only the community owner can grant @admin".to_string());
3206 }
3207 let view = fetch_authority(transport, community).await;
3208 if !session.is_valid() {
3209 return Err("account changed during grant".to_string());
3210 }
3211 let member_hex = member.to_hex();
3212 require_grant_head(community, &view, &member_hex)?;
3213 let role_id = ensure_admin_role(transport, community, &view, true)
3214 .await?
3215 .expect("create_if_missing yields an id");
3216 let mut role_ids = view
3217 .roles
3218 .grants
3219 .iter()
3220 .find(|g| g.member == member_hex)
3221 .map(|g| g.role_ids.clone())
3222 .unwrap_or_default();
3223 if role_ids.contains(&role_id) {
3224 return Ok(()); }
3226 role_ids.push(role_id);
3227 grant_roles(transport, community, member, role_ids).await
3228}
3229
3230pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3233 let session = SessionGuard::capture();
3234 let my_pk = me_pk()?;
3235 if my_pk != community.owner()? {
3236 return Err("only the community owner can revoke @admin".to_string());
3237 }
3238 let view = fetch_authority(transport, community).await;
3239 if !session.is_valid() {
3240 return Err("account changed during revoke".to_string());
3241 }
3242 let member_hex = member.to_hex();
3243 require_grant_head(community, &view, &member_hex)?;
3244 let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
3245 return Ok(()); };
3247 let mut role_ids = view
3248 .roles
3249 .grants
3250 .iter()
3251 .find(|g| g.member == member_hex)
3252 .map(|g| g.role_ids.clone())
3253 .unwrap_or_default();
3254 let before = role_ids.len();
3255 role_ids.retain(|r| r != &role_id);
3256 if role_ids.len() == before {
3257 return Ok(());
3258 }
3259 grant_roles(transport, community, member, role_ids).await
3260}
3261
3262fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
3266 let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
3267 return Err("malformed member key".to_string());
3268 };
3269 let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
3270 if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
3271 return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
3272 }
3273 Ok(())
3274}
3275
3276pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
3279 let session = SessionGuard::capture();
3280 super::roles::validate_banlist(banned)?;
3281 let content = super::roles::banlist_content_json(banned)?;
3282 let eid = super::derive::banlist_locator(community.id());
3283 publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content).await
3284}
3285
3286pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
3289 let session = SessionGuard::capture();
3290 control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
3291 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3292 publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
3293}
3294
3295pub async fn persist_community_image(
3300 id: &crate::community::CommunityId,
3301 img: control::ImageRef,
3302 is_banner: bool,
3303 session: &SessionGuard,
3304) -> Option<CommunityV2> {
3305 let lock = super::realtime::follow_lock(id);
3306 let _guard = lock.lock().await;
3307 if !session.is_valid() {
3308 return None;
3309 }
3310 let mut fresh = crate::db::community::load_community_v2(id).ok()??;
3311 if is_banner {
3312 fresh.banner = Some(img);
3313 } else {
3314 fresh.icon = Some(img);
3315 }
3316 crate::db::community::save_community_v2(&fresh).ok()?;
3317 Some(fresh)
3318}
3319
3320pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
3323 let session = SessionGuard::capture();
3324 let my_pk = me_pk()?;
3325 ensure_channel_manager(community, &my_pk)?;
3326 if meta.private {
3331 if let Some(held) = community.channel(channel_id) {
3332 if !held.private {
3333 return Err("converting a public channel to private is not supported yet".to_string());
3334 }
3335 }
3336 }
3337 control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
3338 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3339 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await
3340}
3341
3342fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
3348 let owner = community.owner()?;
3349 if *me == owner {
3350 return Ok(());
3351 }
3352 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3353 let me_hex = me.to_hex();
3354 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
3355 return Err("you are banned from this community".to_string());
3356 }
3357 let roster = crate::db::community::get_community_roles(&cid_hex)?;
3358 if roster.is_authorized(&me_hex, Some(&owner.to_hex()), crate::community::roles::Permissions::MANAGE_CHANNELS) {
3359 Ok(())
3360 } else {
3361 Err("managing channels here needs the MANAGE_CHANNELS permission".to_string())
3362 }
3363}
3364
3365pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3371 let channel_id = ChannelId(super::super::random_32());
3372 create_public_channel_with_id(transport, community, name, channel_id).await?;
3373 Ok(channel_id)
3374}
3375
3376pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3380 let session = SessionGuard::capture();
3381 let lock = super::realtime::follow_lock(community.id());
3385 let _guard = lock.lock().await;
3386 let my_pk = me_pk()?;
3387 ensure_channel_manager(community, &my_pk)?;
3388 assert_channel_id_free(&channel_id, community.id())?;
3389 let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
3390 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3391 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3392 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3393 if !session.is_valid() {
3394 return Err("account changed during channel create".to_string());
3395 }
3396 let mut updated = community.clone();
3398 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() });
3399 crate::db::community::save_community_v2(&updated)?;
3400 Ok(())
3401}
3402
3403fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
3408 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3409 if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
3410 let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3411 let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
3412 if existing != mine
3413 && matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
3414 {
3415 return Err("channel id is already live in another v2 community".to_string());
3416 }
3417 }
3418 Ok(())
3419}
3420
3421pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3432 let channel_id = ChannelId(super::super::random_32());
3433 create_private_channel_with_id(transport, community, name, channel_id).await?;
3434 Ok(channel_id)
3435}
3436
3437pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3440 let session = SessionGuard::capture();
3441 let lock = super::realtime::follow_lock(community.id());
3445 let _guard = lock.lock().await;
3446 let signer = crate::signer::active_signer()?;
3447 let my_pk = me_pk()?;
3448 ensure_channel_manager(community, &my_pk)?;
3449 assert_channel_id_free(&channel_id, community.id())?;
3450 let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
3451 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3452 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3453
3454 let channel_key = super::super::random_32();
3455 let epoch = Epoch(1);
3456
3457 let mut recipients = memberlist(transport, community).await?;
3459 if !recipients.iter().any(|p| *p == my_pk) {
3460 recipients.push(my_pk);
3461 }
3462 let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
3463 let mut blobs = Vec::with_capacity(recipients.len());
3464 for r in &recipients {
3465 blobs.push(
3466 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
3467 .await
3468 .map_err(|e| e.to_string())?,
3469 );
3470 }
3471 let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
3472 let at_secs = now_ms() / 1000;
3473 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())
3474 .await
3475 .map_err(|e| e.to_string())?;
3476 if !session.is_valid() {
3477 return Err("account changed during channel create".to_string());
3478 }
3479 for c in &chunks {
3480 transport.publish_durable(c, &community.relays).await?;
3481 }
3482 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3483 if !session.is_valid() {
3484 return Err("account changed during channel create".to_string());
3485 }
3486 if crate::db::community::community_protocol(community.id())?.is_none() {
3488 return Err("community removed during channel create".to_string());
3489 }
3490 let mut updated = community.clone();
3491 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() });
3492 crate::db::community::save_community_v2(&updated)?;
3493 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3496 crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
3497 let _ = refresh_public_links(transport, &updated).await;
3499 Ok(())
3500}
3501
3502pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
3505 let session = SessionGuard::capture();
3506 let lock = super::realtime::follow_lock(community.id());
3508 let _guard = lock.lock().await;
3509 let my_pk = me_pk()?;
3510 ensure_channel_manager(community, &my_pk)?;
3511 let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
3515 name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
3516 });
3517 meta.deleted = Some(true);
3518 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3519 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3520 if !session.is_valid() {
3521 return Err("account changed during channel delete".to_string());
3522 }
3523 let mut updated = community.clone();
3524 updated.channels.retain(|c| c.id.0 != channel_id.0);
3525 crate::db::community::save_community_v2(&updated)?;
3526 Ok(())
3527}
3528
3529pub async fn follow_control<T: Transport + ?Sized>(
3553 transport: &T,
3554 community: &CommunityV2,
3555 session: &SessionGuard,
3556) -> Result<Option<CommunityV2>, String> {
3557 community.owner()?; let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
3559 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3560
3561 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
3567 .into_iter()
3568 .filter(|(_, f)| f.0 == community.root_epoch.0)
3569 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
3570 .collect();
3571
3572 let mut editions: Vec<ParsedEdition> = Vec::new();
3576 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
3577 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
3578 let mut oldest: Option<u64> = None;
3579 let mut until: Option<u64> = None;
3580 let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false };
3581 let mut authority = AuthoritySet::owner_only();
3582 let mut truncated = true;
3588 for _ in 0..FOLLOW_MAX_PAGES {
3589 let query = Query {
3593 kinds: vec![stream::KIND_WRAP],
3594 authors: vec![control.pk_hex()],
3595 until,
3596 limit: Some(FOLLOW_PAGE),
3597 evidence: crate::community::transport::Evidence::Quorum,
3598 ..Default::default()
3599 };
3600 let wraps = transport.fetch(&query, &community.relays).await?;
3601 let mut fresh = 0usize;
3605 for w in &wraps {
3606 if !seen_wraps.insert(w.id) {
3607 continue;
3608 }
3609 fresh += 1;
3610 let at = w.created_at.as_secs();
3611 if oldest.is_none_or(|o| at < o) {
3612 oldest = Some(at);
3613 }
3614 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
3617 if seen.insert(ed.inner_id) {
3618 editions.push(ed);
3619 }
3620 }
3621 }
3622 authority = fold_authority(community, &editions, &floors);
3625 fold = apply_control_fold(community, &editions, &floors, &authority);
3626 if !(fold.gapped || authority.gapped) {
3627 truncated = false; break;
3629 }
3630 if fresh == 0 {
3631 truncated = wraps.len() >= FOLLOW_PAGE;
3636 break;
3637 }
3638 until = oldest;
3639 }
3640
3641 if !session.is_valid() {
3644 return Err("account changed during control follow".to_string());
3645 }
3646 if crate::db::community::community_protocol(community.id())?.is_none() {
3649 return Ok(None);
3650 }
3651 for h in fold.heads.iter().chain(authority.heads.iter()) {
3657 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)?;
3658 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)?;
3659 }
3660 let mut authority_changed = false;
3663 let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
3667 if let Some((banned, version)) = &authority.banlist_persist {
3668 let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3669 crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
3670 let mut after = banned.clone();
3671 before.sort();
3672 after.sort();
3673 authority_changed |= before != after;
3674 }
3675 let newest_roster_at: i64 = editions
3682 .iter()
3683 .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
3684 .map(|e| e.created_at as i64)
3685 .max()
3686 .unwrap_or(0);
3687 let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3693 let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
3694 let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
3695 && stored.grants.iter().all(|g| {
3696 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
3697 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
3698 !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
3699 })
3700 });
3701 if !truncated && !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
3706 authority_changed |= stored != authority.roles;
3707 crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
3708 }
3709 if authority_changed {
3714 crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
3715 }
3716 match fold.updated {
3717 Some(u) => {
3718 crate::db::community::save_community_v2(&u)?;
3719 Ok(Some(u))
3720 }
3721 None => Ok(None),
3722 }
3723}
3724
3725const FOLLOW_MAX_PAGES: usize = 32;
3734const FOLLOW_PAGE: usize = 500;
3735const COMPACT_MAX_PAGES: usize = 512;
3740
3741#[derive(Clone)]
3743struct FoldedHead {
3744 entity_hex: String,
3745 version: u64,
3746 self_hash: [u8; 32],
3747 inner_id: [u8; 32],
3748}
3749
3750struct ControlFold {
3756 updated: Option<CommunityV2>,
3757 heads: Vec<FoldedHead>,
3758 gapped: bool,
3759}
3760
3761type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
3763
3764fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
3785 use crate::community::roles::Permissions;
3786 use std::collections::BTreeMap;
3787
3788 let owner_hex = community.owner().ok().map(|o| o.to_hex());
3789
3790 let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
3791 for e in editions {
3792 groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
3793 }
3794
3795 let mut out = community.clone();
3796 let mut changed = false;
3797 let mut heads = Vec::new();
3798 let mut gapped = false;
3799 for ((vsk_code, eid), group) in &groups {
3800 let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
3805 let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
3806 if !is_meta && !is_channel {
3807 continue;
3808 }
3809 let required = if is_meta { Permissions::MANAGE_METADATA } else { Permissions::MANAGE_CHANNELS };
3814 let authed: Vec<&ParsedEdition> = group
3815 .iter()
3816 .copied()
3817 .filter(|e| {
3818 let author = e.author.to_hex();
3819 !authority.banned.contains(&author)
3822 && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
3823 && citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
3827 })
3828 .collect();
3829 if authed.is_empty() {
3830 continue;
3831 }
3832 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
3833 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
3834 let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
3835 gapped |= entity_gapped;
3836 let Some(hi) = hi else { continue };
3837
3838 let head = authed[hi];
3839 heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
3840 if is_meta {
3841 if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
3842 changed |= apply_community_metadata(&mut out, meta);
3843 }
3844 } else if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
3845 changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
3849 }
3850 }
3851 ControlFold { updated: changed.then_some(out), heads, gapped }
3852}
3853
3854fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
3861 let floor_v = floor.map(|f| f.0).unwrap_or(0);
3862 if floor_v == 0 {
3863 return (version::bootstrap_head(fold_eds, 0), false);
3864 }
3865 let floor_hash = floor.map(|f| &f.1);
3866 let held_inner = floor.and_then(|f| f.2);
3867 let result = version::fold(fold_eds, floor_v, floor_hash);
3868 if result.anchored {
3869 return (result.head, result.gap); }
3871 if result.head.is_none() && !result.gap {
3872 return (None, false); }
3874 let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
3878 let win_hash = match fork {
3879 Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
3880 _ => return (None, true), };
3882 let re = version::fold(fold_eds, floor_v, Some(&win_hash));
3883 if !re.anchored {
3884 return (None, true);
3885 }
3886 (re.head, re.gap)
3887}
3888
3889struct AuthoritySet {
3894 roles: crate::community::roles::CommunityRoles,
3895 banned: std::collections::BTreeSet<String>,
3896 heads: Vec<FoldedHead>,
3897 gapped: bool,
3898 banlist_persist: Option<(Vec<String>, u64)>,
3902 banned_at: std::collections::BTreeMap<String, u64>,
3906}
3907
3908impl AuthoritySet {
3909 fn owner_only() -> Self {
3912 AuthoritySet {
3913 roles: Default::default(),
3914 banned: Default::default(),
3915 heads: vec![],
3916 gapped: false,
3917 banlist_persist: None,
3918 banned_at: Default::default(),
3919 }
3920 }
3921}
3922
3923fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
3930 use crate::community::roles::Permissions;
3931 use std::collections::BTreeMap;
3932
3933 let cid = community.id();
3934 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
3935 let owner = community.owner().ok();
3936 let owner_hex = owner.map(|o| o.to_hex());
3937 let banlist_eid = super::derive::banlist_locator(cid);
3938 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
3939
3940 let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
3941 for e in editions {
3942 if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
3943 groups.entry(e.entity_id).or_default().push(e);
3944 }
3945 }
3946
3947 let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
3955 let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
3956 let mut gapped = false;
3957
3958 for (eid, group) in &groups {
3959 if *eid == banlist_eid {
3961 continue;
3962 }
3963 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
3964 let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
3965 let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
3966 gapped |= entity_gapped;
3967 let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
3968
3969 for p in group {
3970 if p.version < floor_v {
3972 continue;
3973 }
3974 let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
3975 match p.vsk.as_str() {
3976 vsk::ROLE => {
3977 if let Some(role) = super::roles::parse_role_content(&p.content) {
3979 if role.role_id == entity_hex && role.position != 0 {
3980 role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
3981 }
3982 }
3983 }
3984 vsk::GRANT => {
3985 if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
3986 if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
3987 if super::derive::grant_locator(cid, &member) == *eid {
3988 grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
3989 grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
3990 }
3991 }
3992 }
3993 }
3994 _ => {}
3995 }
3996 }
3997 }
3998 for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
3999 cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
4000 }
4001
4002 let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4003 let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty);
4006
4007 let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4018 let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
4023 let banlist_authored: Vec<&ParsedEdition> = groups
4024 .get(&banlist_eid)
4025 .map(|g| {
4026 g.iter()
4027 .copied()
4028 .filter(|e| {
4029 let ah = e.author.to_hex();
4030 !banned_authors.contains(ah.as_str())
4031 && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
4032 && citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
4033 })
4034 .collect()
4035 })
4036 .unwrap_or_default();
4037 let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
4043 for p in &banlist_authored {
4044 for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
4045 if owner_hex.as_deref() == Some(t.as_str()) {
4046 continue;
4047 }
4048 let slot = banned_at.entry(t).or_insert(0);
4049 *slot = (*slot).max(p.created_at);
4050 }
4051 }
4052 let mut banlist_persist: Option<(Vec<String>, u64)> = None;
4053 let mut banlist_head: Option<FoldedHead> = None;
4054 let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
4055 persisted_banned.into_iter().collect()
4056 } else {
4057 let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
4058 let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
4059 gapped |= g;
4060 match hi {
4061 Some(hi) => {
4062 let head = banlist_authored[hi];
4063 let ah = head.author.to_hex();
4064 let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
4065 .unwrap_or_default()
4066 .into_iter()
4067 .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
4068 .take(super::roles::MAX_BANLIST)
4069 .collect();
4070 banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
4071 banlist_persist = Some((list.clone(), head.version));
4072 list.into_iter().collect()
4073 }
4074 None => persisted_banned.into_iter().collect(),
4075 }
4076 };
4077
4078 let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned);
4082 if let Some(bh) = banlist_head {
4083 heads.push(bh);
4084 }
4085
4086 if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
4091 authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
4092 authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
4093 let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
4094 authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
4095 authorized.grants.retain(|g| !g.role_ids.is_empty());
4096 }
4097
4098 AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
4099}
4100
4101struct AuthorityCand {
4104 role: Option<crate::community::roles::Role>,
4105 grant: Option<crate::community::roles::MemberGrant>,
4106 author: PublicKey,
4107 head: FoldedHead,
4108 citation: Option<crate::community::edition::AuthorityCitation>,
4111}
4112
4113fn citation_ok_in_fold(
4122 cid: &crate::community::CommunityId,
4123 heads: &[FoldedHead],
4124 owner_hex: Option<&str>,
4125 author: &PublicKey,
4126 citation: Option<&crate::community::edition::AuthorityCitation>,
4127) -> bool {
4128 let actor_hex = author.to_hex();
4129 if owner_hex == Some(actor_hex.as_str()) {
4130 return true;
4131 }
4132 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
4133 let as_entity: Vec<crate::community::roster::EntityHead> = heads
4134 .iter()
4135 .map(|h| crate::community::roster::EntityHead {
4136 entity_hex: h.entity_hex.clone(),
4137 version: h.version,
4138 self_hash: h.self_hash,
4139 inner_id: h.inner_id,
4140 citation: None,
4141 })
4142 .collect();
4143 crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
4144}
4145
4146fn select_authorized(
4155 cid: &crate::community::CommunityId,
4156 role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
4157 grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
4158 owner_hex: Option<&str>,
4159 excluded: &std::collections::BTreeSet<String>,
4160) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
4161 use crate::community::roles::{CommunityRoles, Permissions};
4162 let mut accepted = CommunityRoles::default();
4163 let mut heads: Vec<FoldedHead> = Vec::new();
4164 let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
4169 for _ in 0..bound {
4170 let mut next = CommunityRoles::default();
4171 let mut next_heads: Vec<FoldedHead> = Vec::new();
4172
4173 for cands in role_cands.values() {
4174 let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
4187 let mut standing: Option<u32> = None;
4188 let mut i = cands.len();
4189 while i > 0 {
4190 let hi = i;
4191 let ver = cands[i - 1].head.version;
4192 while i > 0 && cands[i - 1].head.version == ver {
4193 i -= 1;
4194 }
4195 for c in cands[i..hi].iter().rev() {
4197 let Some(role) = &c.role else { continue };
4198 let ah = c.author.to_hex();
4199 if excluded.contains(&ah) || role.position == 0 {
4200 continue;
4201 }
4202 if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
4203 continue;
4204 }
4205 if let Some(prev) = standing {
4206 if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
4207 continue;
4208 }
4209 }
4210 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
4211 continue;
4212 }
4213 admissible.insert(c.head.self_hash);
4214 standing = Some(role.position);
4215 break;
4216 }
4217 }
4218 for c in cands {
4219 let Some(role) = &c.role else { continue };
4220 if !admissible.contains(&c.head.self_hash) {
4221 continue;
4222 }
4223 next.roles.push(role.clone());
4224 next_heads.push(c.head.clone());
4225 break; }
4227 }
4228 for cands in grant_cands.values() {
4229 for c in cands {
4230 let Some(grant) = &c.grant else { continue };
4231 let ah = c.author.to_hex();
4232 if excluded.contains(&ah) || excluded.contains(&grant.member) {
4233 continue;
4234 }
4235 let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
4238 let Some(positions) = positions else { continue };
4239 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
4240 continue;
4241 }
4242 if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
4243 && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
4244 {
4245 next_heads.push(c.head.clone());
4249 if !grant.role_ids.is_empty() {
4250 next.grants.push(grant.clone());
4251 }
4252 break;
4253 }
4254 }
4255 }
4256
4257 let converged = next.roles == accepted.roles && next.grants == accepted.grants;
4258 accepted = next;
4259 heads = next_heads;
4260 if converged {
4261 break;
4262 }
4263 }
4264 (accepted, heads)
4265}
4266
4267fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
4271 let mut changed = false;
4272 if out.name != meta.name {
4273 out.name = meta.name;
4274 changed = true;
4275 }
4276 if out.description != meta.description {
4277 out.description = meta.description;
4278 changed = true;
4279 }
4280 if out.icon != meta.icon {
4284 out.icon = meta.icon;
4285 changed = true;
4286 }
4287 if out.banner != meta.banner {
4288 out.banner = meta.banner;
4289 changed = true;
4290 }
4291 if out.meta_custom != meta.custom {
4294 out.meta_custom = meta.custom;
4295 changed = true;
4296 }
4297 if out.meta_extra != meta.extra {
4298 out.meta_extra = meta.extra;
4299 changed = true;
4300 }
4301 let relays = crate::community::cap_relays(meta.relays);
4309 if !relays.is_empty() && out.relays != relays {
4310 out.relays = relays;
4311 changed = true;
4312 }
4313 changed
4314}
4315
4316fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
4321 let deleted = meta.deleted.unwrap_or(false);
4322 if deleted {
4323 let before = out.channels.len();
4324 out.channels.retain(|c| c.id.0 != id.0);
4325 return out.channels.len() != before;
4326 }
4327 match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
4328 Some(existing) => {
4329 let mut changed = false;
4330 if existing.name != meta.name {
4331 existing.name = meta.name;
4332 changed = true;
4333 }
4334 if existing.voice != meta.voice {
4337 existing.voice = meta.voice;
4338 changed = true;
4339 }
4340 if existing.meta_custom != meta.custom {
4341 existing.meta_custom = meta.custom;
4342 changed = true;
4343 }
4344 if existing.meta_extra != meta.extra {
4345 existing.meta_extra = meta.extra;
4346 changed = true;
4347 }
4348 if !meta.private && (existing.private || existing.key.is_some()) {
4357 existing.private = false;
4358 existing.key = None;
4359 changed = true;
4360 }
4361 changed
4362 }
4363 None if !meta.private => {
4364 out.channels.push(ChannelV2 {
4367 id,
4368 name: meta.name,
4369 private: false,
4370 key: None,
4371 epoch: out.root_epoch,
4372 voice: meta.voice,
4373 meta_custom: meta.custom,
4374 meta_extra: meta.extra,
4375 });
4376 true
4377 }
4378 None => {
4379 out.channels.push(ChannelV2 {
4385 id,
4386 name: meta.name,
4387 private: true,
4388 key: None,
4389 epoch: Epoch(0),
4390 voice: meta.voice,
4391 meta_custom: meta.custom,
4392 meta_extra: meta.extra,
4393 });
4394 true
4395 }
4396 }
4397}
4398
4399pub struct RekeyFollow {
4403 pub updated: Option<CommunityV2>,
4406 pub self_removed: bool,
4409 pub dissolved: bool,
4412}
4413
4414const MAX_ADDRESSING_ROOTS: usize = 8;
4421
4422pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
4431 let mut roots: Vec<[u8; 32]> = vec![cur_root];
4432 let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
4433 .unwrap_or_default();
4434 archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
4435 for (_, r) in archived {
4436 if !roots.contains(&r) {
4437 roots.push(r);
4438 }
4439 }
4440 roots.truncate(MAX_ADDRESSING_ROOTS);
4441 roots
4442}
4443
4444#[cfg(debug_assertions)]
4479pub async fn debug_explain_base_rekey<T: Transport + ?Sized>(
4480 transport: &T,
4481 community: &CommunityV2,
4482) -> Result<serde_json::Value, String> {
4483 let my_xonly = me_pk()?.to_bytes();
4484 let owner = community.owner()?;
4485 let owner_hex = owner.to_hex();
4486 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4487 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4488 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4489 let held_epoch = community.root_epoch;
4490 let held_key = community.community_root;
4491 let next = Epoch(held_epoch.0.saturating_add(1));
4492 let group = base_rekey_group_key(&held_key, community.id(), next);
4493 let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
4494 let rotations = rekey::collect_rotations(&chunks);
4495
4496 let reports: Vec<serde_json::Value> = rotations
4497 .iter()
4498 .map(|r| {
4499 let rotator_is_owner = r.rotator == owner;
4500 let rotator_authorized = rotator_is_owner
4503 || (!banned.contains(&r.rotator.to_hex())
4504 && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
4505 let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
4506 let epoch_ok = r.new_epoch.0 == next.0;
4507 let complete = r.is_complete();
4508 let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
4509 let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
4510 let owner_kept = r.rotator == owner
4515 || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
4516 let verdict = if !rotator_authorized {
4518 "REJECTED: rotator holds no BAN authority in the folded roster"
4519 } else if !scope_ok {
4520 "REJECTED: scope is not Root"
4521 } else if !epoch_ok {
4522 "REJECTED: new_epoch != held+1"
4523 } else if !complete {
4524 "WAIT: rotation incomplete (missing chunk) — never concludes removal"
4525 } else if continuity != "Extends" {
4526 "REJECTED: continuity does not extend my held root (FORK/GAP)"
4527 } else if has_my_blob {
4528 "ADOPT: authorized + complete + continuous + my blob present"
4529 } else {
4530 "REMOVED: complete authorized rotation with no blob for me"
4531 };
4532 serde_json::json!({
4533 "rotator": r.rotator.to_hex(),
4534 "rotator_is_recorded_owner": rotator_is_owner,
4535 "rotator_authorized_ban": rotator_authorized,
4536 "scope_is_root": scope_ok,
4537 "new_epoch": r.new_epoch.0,
4538 "prev_epoch": r.prev_epoch.0,
4539 "declared_chunks": r.declared_chunks,
4540 "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
4541 "is_complete": complete,
4542 "continuity_vs_held_root": continuity,
4543 "my_blob_present": has_my_blob,
4544 "owner_kept": owner_kept,
4545 "blob_count": r.blobs.len(),
4546 "verdict": verdict,
4547 })
4548 })
4549 .collect();
4550
4551 Ok(serde_json::json!({
4552 "recorded_owner": owner.to_hex(),
4553 "held_root_epoch": held_epoch.0,
4554 "probing_next_epoch": next.0,
4555 "base_plane_pk": group.pk_hex(),
4556 "raw_chunks_parsed": chunks.len(),
4557 "rotations_found": rotations.len(),
4558 "rotations": reports,
4559 }))
4560}
4561
4562pub async fn follow_rekeys<T: Transport + ?Sized>(
4563 transport: &T,
4564 community: &CommunityV2,
4565 session: &SessionGuard,
4566) -> Result<RekeyFollow, String> {
4567 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4570 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
4571 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
4572 }
4573 if is_dissolved(transport, community).await {
4579 if session.is_valid() {
4580 let _ = crate::db::community::set_community_dissolved(&cid_hex);
4581 }
4582 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
4583 }
4584 let signer = crate::signer::active_signer()?;
4585 let my_pk = me_pk()?;
4586 let my_xonly = my_pk.to_bytes();
4587 let owner = community.owner()?;
4588 let owner_hex = owner.to_hex();
4589 let mut cur = community.clone();
4590 let mut changed = false;
4591
4592 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4604 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4605 let me_hex = my_pk.to_hex();
4606 let cited_ok = |rot: &rekey::Rotation| -> bool {
4615 citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
4616 };
4617 let channel_rotator_ok = |rotator: &PublicKey| -> bool {
4618 if *rotator == owner {
4619 return true;
4620 }
4621 let rh = rotator.to_hex();
4622 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
4623 };
4624 let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
4629 if *rotator == owner {
4630 return true;
4631 }
4632 let rh = rotator.to_hex();
4633 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
4634 };
4635 let base_rotator_ok = |rotator: &PublicKey| -> bool {
4641 if *rotator == owner {
4642 return true;
4643 }
4644 let rh = rotator.to_hex();
4645 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
4646 };
4647 let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
4653 if *rotator == owner {
4654 return true;
4655 }
4656 let rh = rotator.to_hex();
4657 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
4658 };
4659
4660 const MAX_STEPS: usize = 128;
4664 for _ in 0..MAX_STEPS {
4665 let mut advanced = false;
4666
4667 let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
4672
4673 let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
4676 for cid in channel_ids {
4677 let (held_key, held_epoch) = match cur.channel(&cid) {
4678 Some(ch) => (ch.key, ch.epoch),
4679 None => continue,
4680 };
4681 let next = Epoch(held_epoch.0.saturating_add(1));
4682 let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
4683 let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
4684 for (ri, root) in addressing_roots.iter().enumerate() {
4687 let group = channel_rekey_group_key(root, &cid, next);
4688 let chunks = match fetch_rekey_chunks(transport, &cur.relays, &group).await {
4689 Ok(c) => c,
4690 Err(e) => {
4691 crate::log_warn!(
4692 "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
4693 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
4694 );
4695 return Err(e);
4696 }
4697 };
4698 if chunks.is_empty() {
4699 continue;
4700 }
4701 crate::log_debug!(
4702 "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
4703 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
4704 );
4705 batches.push((chunks, held_key.map(|k| (held_epoch, k))));
4706 }
4707 match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
4714 Advance::Adopt { new_key } => {
4715 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
4716 ch.key = Some(new_key);
4717 ch.epoch = next;
4718 }
4719 crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
4720 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) {
4725 crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
4726 }
4727 advanced = true;
4728 changed = true;
4729 }
4730 Advance::Removed => {
4731 match held_key {
4732 Some(_) => {
4734 cur.channels.retain(|c| c.id.0 != cid.0);
4735 }
4736 None => {
4741 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
4742 ch.epoch = next;
4743 }
4744 }
4745 }
4746 advanced = true;
4747 changed = true;
4748 }
4749 Advance::Stay => {}
4750 }
4751 }
4752
4753 {
4757 let held_epoch = cur.root_epoch;
4758 let held_key = cur.community_root;
4759 let next = Epoch(held_epoch.0.saturating_add(1));
4760 let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
4761 let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
4762 let batches = vec![(chunks, Some((held_epoch, held_key)))];
4763 let base_admissible = |r: &rekey::Rotation| -> bool {
4770 if r.rotator == owner {
4771 return true; }
4773 if !cited_ok(r) {
4777 return false;
4778 }
4779 let rotator_hex = r.rotator.to_hex();
4780 let has_blob = |xonly: &[u8; 32]| {
4781 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
4782 };
4783 if !has_blob(&owner.to_bytes()) {
4785 return false;
4786 }
4787 for g in &roster.grants {
4788 if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
4789 continue; }
4791 if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
4793 if let Ok(pk) = PublicKey::from_hex(&g.member) {
4794 if !has_blob(&pk.to_bytes()) {
4795 return false; }
4797 }
4798 }
4799 }
4800 true
4801 };
4802 match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
4803 Advance::Adopt { new_key } => {
4804 cur.community_root = new_key;
4805 cur.root_epoch = next;
4806 if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
4810 crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
4811 }
4812 advanced = true;
4813 changed = true;
4814 }
4815 Advance::Removed => {
4816 if !session.is_valid() {
4817 return Err("account changed during rekey follow".to_string());
4818 }
4819 return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
4820 }
4821 Advance::Stay => {}
4822 }
4823 }
4824
4825 if !advanced {
4826 break;
4827 }
4828 }
4829
4830 if !changed {
4831 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
4832 }
4833 if !session.is_valid() {
4834 return Err("account changed during rekey follow".to_string());
4835 }
4836 if crate::db::community::community_protocol(community.id())?.is_none() {
4839 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
4840 }
4841 crate::db::community::save_community_v2(&cur)?;
4842 let _ = refresh_public_links(transport, &cur).await;
4850 Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
4851}
4852
4853enum Advance {
4856 Adopt { new_key: [u8; 32] },
4858 Removed,
4860 Stay,
4862}
4863
4864async fn fetch_rekey_chunks<T: Transport + ?Sized>(
4866 transport: &T,
4867 relays: &[String],
4868 group: &GroupKey,
4869) -> Result<Vec<rekey::RekeyChunk>, String> {
4870 const REKEY_PAGE: usize = 200;
4878 const REKEY_MAX_PAGES: usize = 6;
4879 let mut out = Vec::new();
4880 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
4881 let mut until: Option<u64> = None;
4882 let mut oldest: Option<u64> = None;
4883 for _ in 0..REKEY_MAX_PAGES {
4884 let query = Query {
4885 kinds: vec![stream::KIND_WRAP],
4886 authors: vec![group.pk_hex()],
4887 until,
4888 limit: Some(REKEY_PAGE),
4889 ..Default::default()
4890 };
4891 let wraps = transport.fetch_plane(group.keys(), &query, relays).await?;
4896 let mut fresh = 0usize;
4897 for w in &wraps {
4898 if !seen.insert(w.id) {
4899 continue;
4900 }
4901 fresh += 1;
4902 let at = w.created_at.as_secs();
4903 if oldest.is_none_or(|o| at < o) {
4904 oldest = Some(at);
4905 }
4906 if let Ok(opened) = stream::open_wrap(w, group) {
4907 if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
4908 out.push(chunk);
4909 }
4910 }
4911 }
4912 if fresh == 0 || wraps.len() < REKEY_PAGE {
4915 break;
4916 }
4917 match oldest {
4918 Some(o) if o > 0 => until = Some(o),
4919 _ => break,
4920 }
4921 }
4922 Ok(out)
4923}
4924
4925async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
4939 batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
4940 scope: RekeyScope,
4941 rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
4942 rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
4943 admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
4944 signer: &S,
4945 my_xonly: &[u8; 32],
4946 next_epoch: Epoch,
4947) -> Advance {
4948 let mut winners: Vec<[u8; 32]> = Vec::new();
4949 let mut saw_complete_candidate = false;
4950 let mut saw_outranking_candidate = false;
4951 let keyed = batches.iter().any(|(_, held)| held.is_some());
4952 for (chunks, held) in batches {
4953 let rotations = rekey::collect_rotations(chunks);
4954 for r in &rotations {
4955 if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
4956 continue;
4957 }
4958 if let Some((held_epoch, held_key)) = held {
4959 if r.continuity(*held_epoch, held_key) != Continuity::Extends {
4960 continue;
4961 }
4962 }
4963 if !admissible(r) {
4969 continue;
4970 }
4971 saw_complete_candidate = true;
4972 saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
4973 if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
4974 if let Ok(k) = rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob).await {
4975 winners.push(k);
4976 }
4977 }
4978 }
4979 }
4980 if !winners.is_empty() {
4981 let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
4989 return Advance::Adopt { new_key: winners[idx] };
4990 }
4991 if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
4992 Advance::Removed
4993 } else {
4994 Advance::Stay
4995 }
4996}
4997
4998#[cfg(test)]
4999mod tests {
5000 use super::super::super::transport::memory::MemoryRelay;
5001 use super::*;
5002 use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
5003
5004 fn account_name(n: u32) -> String {
5006 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
5007 let mut acct = String::from("npub1");
5008 let mut v = n as usize;
5009 for _ in 0..58 {
5010 acct.push(B[v % 32] as char);
5011 v = v / 32 + 7;
5012 }
5013 acct
5014 }
5015
5016 struct Actor {
5018 keys: Keys,
5019 account: String,
5020 }
5021
5022 struct TestBed {
5028 _tmp: tempfile::TempDir,
5029 _guard: std::sync::MutexGuard<'static, ()>,
5030 relay: MemoryRelay,
5031 relays: Vec<String>,
5032 }
5033
5034 impl TestBed {
5035 fn new() -> (TestBed, Actor, Actor) {
5036 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
5037 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5038 crate::db::close_database();
5039 crate::db::clear_id_caches();
5040 let tmp = tempfile::tempdir().unwrap();
5041 crate::db::set_app_data_dir(tmp.path().to_path_buf());
5042
5043 let mk = || {
5044 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5045 let account = account_name(n);
5046 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
5047 crate::db::set_current_account(account.clone()).unwrap();
5048 crate::db::init_database(&account).unwrap();
5049 Actor { keys: Keys::generate(), account }
5050 };
5051 let owner = mk();
5052 let member = mk();
5053 let _ = crate::state::take_nostr_client();
5054 let bed = TestBed {
5055 _tmp: tmp,
5056 _guard: guard,
5057 relay: MemoryRelay::new(),
5058 relays: vec!["wss://r".to_string()],
5059 };
5060 (bed, owner, member)
5061 }
5062
5063 fn swap_to(&self, actor: &Actor) {
5068 crate::state::bump_session_generation();
5069 crate::db::set_current_account(actor.account.clone()).unwrap();
5070 crate::db::init_database(&actor.account).unwrap();
5071 crate::db::clear_id_caches();
5072 crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
5073 crate::state::set_my_public_key(actor.keys.public_key());
5074 }
5075 }
5076
5077 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
5079 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5080 crate::db::close_database();
5081 crate::db::clear_id_caches();
5082 let tmp = tempfile::tempdir().unwrap();
5083 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
5084 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5085 let acct = account_name(n);
5086 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
5087 crate::db::set_app_data_dir(tmp.path().to_path_buf());
5088 crate::db::set_current_account(acct.clone()).unwrap();
5089 crate::db::init_database(&acct).unwrap();
5090 let _ = crate::state::take_nostr_client();
5091 let owner = Keys::generate();
5092 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
5093 crate::state::set_my_public_key(owner.public_key());
5094 (tmp, guard, owner)
5095 }
5096
5097 struct SwapMidFetch {
5100 inner: MemoryRelay,
5101 }
5102 #[async_trait::async_trait]
5103 impl Transport for SwapMidFetch {
5104 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5105 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
5106 self.inner.publish(e, r).await
5107 }
5108 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5109 self.inner.publish_durable(e, r).await
5110 }
5111 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
5112 let out = self.inner.fetch(q, r).await;
5113 crate::state::bump_session_generation();
5114 out
5115 }
5116 }
5117
5118 struct FixedFetch {
5123 events: Vec<Event>,
5124 }
5125 #[async_trait::async_trait]
5126 impl Transport for FixedFetch {
5127 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5128 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
5129 Ok(())
5130 }
5131 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
5132 Ok(())
5133 }
5134 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
5135 Ok(self.events.clone())
5136 }
5137 }
5138
5139 async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
5142 let q = Query {
5143 kinds: vec![stream::KIND_WRAP],
5144 p_tags: vec![me.to_hex()],
5145 k_tags: vec!["3313".to_string()],
5146 ..Default::default()
5147 };
5148 relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
5149 }
5150
5151 #[tokio::test]
5152 async fn create_persists_and_reloads_a_v2_community() {
5153 let (_tmp, _guard, owner) = init_test_db();
5154 let relay = MemoryRelay::new();
5155 let relays = vec!["wss://r".to_string()];
5156
5157 let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
5158 assert!(created.identity.verify());
5159 assert_eq!(created.owner().unwrap(), owner.public_key());
5160 assert_eq!(created.channels.len(), 1);
5161
5162 assert_eq!(
5164 crate::db::community::community_protocol(created.id()).unwrap(),
5165 Some(crate::community::ConcordProtocol::V2)
5166 );
5167 let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
5168 assert_eq!(loaded.name, "Vectorville");
5169 assert_eq!(loaded.community_root, created.community_root);
5170 assert_eq!(loaded.identity, created.identity);
5171 assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
5172 assert!(!loaded.channels[0].private);
5173
5174 assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
5176 }
5177
5178 #[tokio::test]
5179 async fn owner_sends_and_reads_back_a_message() {
5180 let (_tmp, _guard, _owner) = init_test_db();
5181 let relay = MemoryRelay::new();
5182 let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
5183 let general = community.channels[0].id;
5184
5185 let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
5186 let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
5187 assert_ne!(id1, id2);
5188
5189 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
5190 let texts: Vec<String> = page
5191 .iter()
5192 .filter_map(|f| match &f.event {
5193 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
5194 _ => None,
5195 })
5196 .collect();
5197 assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
5198 }
5199
5200 #[tokio::test]
5201 async fn a_second_member_reads_the_public_channel_from_the_root() {
5202 let (_tmp, _guard, _owner) = init_test_db();
5206 let relay = MemoryRelay::new();
5207 let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
5208 let general = community.channels[0].id;
5209 send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
5210
5211 let member_view = community.clone();
5213 let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
5214 assert_eq!(page.len(), 1);
5215 assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
5216 assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
5217 }
5218
5219 async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
5222 fetch_channel(relay, community, channel, 100)
5223 .await
5224 .unwrap()
5225 .iter()
5226 .filter_map(|f| match &f.event {
5227 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
5228 _ => None,
5229 })
5230 .collect()
5231 }
5232
5233 #[tokio::test]
5234 async fn direct_invite_full_loop_owner_and_member_converse() {
5235 let (bed, owner, member) = TestBed::new();
5236
5237 bed.swap_to(&owner);
5239 let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
5240 let general = community.channels[0].id;
5241 send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
5242 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
5243
5244 bed.swap_to(&member);
5246 assert!(
5247 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
5248 "the member does not hold the community before joining"
5249 );
5250 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
5251 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
5252 assert_eq!(joined.id().0, community.id().0, "joined the same community");
5253 assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
5254 assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
5255
5256 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
5258 send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
5259
5260 bed.swap_to(&owner);
5262 assert_eq!(
5263 texts_in(&bed.relay, &community, &general).await,
5264 vec!["owner: welcome!", "member: thanks for the invite"],
5265 "both actors' messages interleave in ms order on the shared channel"
5266 );
5267
5268 let members = memberlist(&bed.relay, &community).await.unwrap();
5270 assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
5271 assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
5272 assert_eq!(members.len(), 2);
5273 }
5274
5275 #[tokio::test]
5279 async fn a_banned_member_is_refused_at_join_time() {
5280 let (bed, owner, member) = TestBed::new();
5281
5282 bed.swap_to(&owner);
5283 let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
5284 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
5285 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
5286
5287 bed.swap_to(&member);
5288 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
5289 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
5290 assert!(err.contains("banned"), "refusal names the reason: {err}");
5291 assert!(
5292 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
5293 "a refused join persists nothing"
5294 );
5295
5296 bed.swap_to(&owner);
5299 set_banlist(&bed.relay, &community, &[]).await.unwrap();
5300 bed.swap_to(&member);
5301 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
5302 assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
5303 }
5304
5305 #[tokio::test]
5309 async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
5310 use crate::community::migration;
5311 let (bed, owner, member) = TestBed::new();
5312
5313 bed.swap_to(&owner);
5315 let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
5316 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
5317 let jm = join_material(&v2);
5318
5319 bed.swap_to(&member);
5322 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5323 let v1_cid = v1.id.to_hex();
5324 v1.owner_attestation = Some({
5325 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5326 .finalize(&owner.keys).unwrap().as_json()
5327 });
5328 crate::db::community::save_community(&v1).unwrap();
5329 let v1_channel = v1.channels[0].id.to_hex();
5330
5331 let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
5333 let signpost = migration::MigrationSignpost {
5334 v2_community_id: v2_hex.clone(),
5335 owner_xonly: owner.keys.public_key().to_hex(),
5336 owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
5337 relays: bed.relays.clone(),
5338 name: "Guild".into(),
5339 primary_channel: v1_channel.clone(),
5340 root_epoch: 0,
5341 };
5342 let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
5343 crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
5344
5345 let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
5347 assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
5348
5349 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
5351 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
5352 assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
5353 let _ = v1_channel;
5354
5355 assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
5357 }
5358
5359 #[tokio::test]
5364 async fn owner_wizard_then_member_migrate_and_stitch() {
5365 use crate::community::migration;
5366 let (bed, owner, member) = TestBed::new();
5367 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
5368
5369 bed.swap_to(&owner);
5371 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5372 let v1_cid = v1.id.to_hex();
5373 let v1_channel = v1.channels[0].id.to_hex();
5374 v1.owner_attestation = Some({
5375 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5376 .finalize(&owner.keys).unwrap().as_json()
5377 });
5378 crate::db::community::save_community(&v1).unwrap();
5379
5380 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
5382 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
5383 "owner's own client flipped to v2");
5384 assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
5387 "owner channel stitched to v2");
5388
5389 bed.swap_to(&member);
5391 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5392 m_v1.id = v1.id;
5394 m_v1.server_root_key = v1.server_root_key.clone();
5395 m_v1.channels[0].id = v1.channels[0].id;
5396 m_v1.owner_attestation = v1.owner_attestation.clone();
5397 crate::db::community::save_community(&m_v1).unwrap();
5398
5399 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
5402 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
5403 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
5404 "the FOLD ITSELF flipped the member (auto-drive)");
5405 assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
5406 "member holds the v2 twin");
5407 assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
5409 }
5410
5411 #[tokio::test]
5416 async fn wizard_publishes_the_twin_to_the_cross_device_list() {
5417 use crate::community::migration;
5418 let (bed, owner, _member) = TestBed::new();
5419 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
5420
5421 bed.swap_to(&owner);
5422 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5423 let v1_cid = v1.id.to_hex();
5424 v1.owner_attestation = Some({
5425 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5426 .finalize(&owner.keys).unwrap().as_json()
5427 });
5428 crate::db::community::save_community(&v1).unwrap();
5429
5430 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
5431
5432 let list = fetch_community_list(&bed.relay, &bed.relays).await.unwrap()
5434 .expect("the wizard published a community list");
5435 assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
5436 assert!(
5440 !list.tombstones.iter().any(|t| t.community_id == v1_cid),
5441 "migration must not tombstone the v1 community"
5442 );
5443 }
5444
5445 #[tokio::test]
5450 async fn wizard_refuses_while_a_drive_holds_the_claim() {
5451 use crate::community::migration;
5452 let (bed, owner, _member) = TestBed::new();
5453 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
5454
5455 bed.swap_to(&owner);
5456 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5457 let v1_cid = v1.id.to_hex();
5458 v1.owner_attestation = Some({
5459 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5460 .finalize(&owner.keys).unwrap().as_json()
5461 });
5462 crate::db::community::save_community(&v1).unwrap();
5463
5464 migration::test_hold_drive_claim(&v1_cid);
5466 let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
5467 assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
5468 assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
5470 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
5471
5472 migration::test_release_drive_claim(&v1_cid);
5474 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
5475 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
5476 }
5477
5478 #[tokio::test]
5484 async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
5485 use crate::community::migration;
5486 let (bed, owner, _member) = TestBed::new();
5487 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
5488 let relay = std::sync::Arc::new(MemoryRelay::new());
5490
5491 bed.swap_to(&owner);
5492 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5493 let v1_cid = v1.id.to_hex();
5494 let v1_channel = v1.channels[0].id.to_hex();
5495 v1.owner_attestation = Some({
5496 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5497 .finalize(&owner.keys).unwrap().as_json()
5498 });
5499 crate::db::community::save_community(&v1).unwrap();
5500
5501 let twin = create_migration_twin(
5504 &*relay, "Guild", bed.relays.clone(), None,
5505 (v1.channels[0].id, "general".to_string()),
5506 ).await.unwrap();
5507 let v2_id = twin.identity.community_id;
5508 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
5509 crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
5510
5511 let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
5513
5514 let wizard = tokio::spawn({
5515 let relay = relay.clone();
5516 let v1 = v1.clone();
5517 async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
5518 });
5519
5520 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
5522 assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
5523 assert!(
5524 crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
5525 "the fence must not be stamped while the follow lock is held"
5526 );
5527
5528 drop(held);
5530 let flipped = wizard.await.unwrap().unwrap();
5531 assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
5532 assert_eq!(
5533 crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
5534 Some(v2_hex.as_str()),
5535 "the channel row is stitched to the twin, not pruned"
5536 );
5537 }
5538
5539 #[tokio::test]
5544 async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
5545 use crate::community::migration;
5546 let (bed, owner, banned) = TestBed::new();
5547 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
5548
5549 bed.swap_to(&owner);
5551 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5552 let v1_cid = v1.id.to_hex();
5553 v1.owner_attestation = Some({
5554 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5555 .finalize(&owner.keys).unwrap().as_json()
5556 });
5557 crate::db::community::save_community(&v1).unwrap();
5558 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
5559
5560 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
5561
5562 bed.swap_to(&banned);
5564 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5565 m_v1.id = v1.id;
5566 m_v1.server_root_key = v1.server_root_key.clone();
5567 m_v1.channels[0].id = v1.channels[0].id;
5568 m_v1.owner_attestation = v1.owner_attestation.clone();
5569 crate::db::community::save_community(&m_v1).unwrap();
5570 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
5571
5572 let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
5574 let payload = migration::parse_migration_payload(&raw).unwrap();
5575 assert!(payload.m.is_some());
5576 let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
5577 assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
5578 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
5579 assert!(
5580 crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
5581 "banned member never acquires the v2 twin"
5582 );
5583 }
5584
5585 #[tokio::test]
5590 async fn wizard_resume_continues_on_the_same_twin() {
5591 use crate::community::migration;
5592 let (bed, owner, banned) = TestBed::new();
5593 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
5594
5595 bed.swap_to(&owner);
5596 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5597 let mut sibling = v1.channels[0].clone();
5599 sibling.id = crate::community::ChannelId(crate::community::random_32());
5600 sibling.name = "offtopic".into();
5601 v1.channels.push(sibling.clone());
5602 let v1_cid = v1.id.to_hex();
5603 v1.owner_attestation = Some({
5604 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5605 .finalize(&owner.keys).unwrap().as_json()
5606 });
5607 crate::db::community::save_community(&v1).unwrap();
5608 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
5609
5610 let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
5613 let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
5614 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
5615
5616 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
5618 assert_eq!(v2_hex, minted_hex, "no second twin was minted");
5619 let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
5620 assert_eq!(ledger_v2, minted_hex);
5621 assert_eq!(phase, migration::PHASE_FLIPPED);
5622 assert_eq!(
5625 crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
5626 Some(minted_hex.as_str()),
5627 "sibling channel re-parented by the resumed run"
5628 );
5629 let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
5630 let (_, _, wire_banlist) = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
5631 .await
5632 .map(|(c, h, b)| (c, h, b))
5633 .unwrap();
5634 assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
5635 "the resumed banlist clone is folded from the twin's wire control plane");
5636
5637 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
5640 let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
5641 assert_eq!(healed, minted_hex);
5642 let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
5643 assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
5644
5645 let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
5651 let v1b_cid = v1b.id.to_hex();
5652 v1b.owner_attestation = Some({
5653 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
5654 .finalize(&owner.keys).unwrap().as_json()
5655 });
5656 crate::db::community::save_community(&v1b).unwrap();
5657 let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
5658 let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
5659 crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
5660 crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
5662 assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
5663 assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
5664 }
5665
5666 #[tokio::test]
5671 async fn birth_refound_seeds_an_explicit_roster() {
5672 let (bed, owner, _m) = TestBed::new();
5673 bed.swap_to(&owner);
5674 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
5675 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
5676 assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
5677 let ghost_a = Keys::generate().public_key();
5679 let ghost_b = Keys::generate().public_key();
5680
5681 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
5682 assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
5683
5684 let members = memberlist(&bed.relay, &rolled).await.unwrap();
5687 assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
5688 assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
5689
5690 let (_, _, _banlist) = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
5694 .expect("the epoch-1 twin verifies from its compacted control plane");
5695
5696 let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
5699 assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
5700 assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
5701 }
5702
5703 #[tokio::test]
5707 async fn birth_refound_ignores_a_banned_seed_entry() {
5708 let (bed, owner, _m) = TestBed::new();
5709 bed.swap_to(&owner);
5710 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
5711 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
5712 let good = Keys::generate().public_key();
5713 let banned = Keys::generate();
5714 set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
5716 let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
5717
5718 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
5719 .expect("a banned seed entry is filtered, not a permanent verify-back wedge");
5720 assert_eq!(rolled.root_epoch, Epoch(1));
5721 let members = memberlist(&bed.relay, &rolled).await.unwrap();
5722 assert!(members.contains(&good), "the non-banned seed lands");
5723 assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
5724 }
5725
5726 #[tokio::test]
5732 async fn a_seeded_member_receives_a_later_refound_rekey() {
5733 let (bed, owner, _m) = TestBed::new();
5734 bed.swap_to(&owner);
5735 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
5736 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
5737 let ghost = Keys::generate();
5738 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
5740 assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
5741
5742 let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
5748 assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
5749 assert!(
5750 memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
5751 "a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
5752 );
5753 }
5754
5755 #[tokio::test]
5758 async fn v1_admin_stays_admin_across_migration() {
5759 use crate::community::migration;
5760 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
5761 let (bed, owner, admin) = TestBed::new();
5762 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
5763
5764 bed.swap_to(&owner);
5765 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5766 let v1_cid = v1.id.to_hex();
5767 v1.owner_attestation = Some({
5768 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5769 .finalize(&owner.keys).unwrap().as_json()
5770 });
5771 crate::db::community::save_community(&v1).unwrap();
5772 let admin_role = Role::admin("a1".repeat(32));
5774 let roles = CommunityRoles {
5775 roles: vec![admin_role.clone()],
5776 grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
5777 };
5778 crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
5779
5780 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
5781 let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
5782
5783 let authority = fetch_authority(&bed.relay, &twin).await;
5785 assert!(
5786 authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
5787 "the v1 admin is an admin on the v2 twin"
5788 );
5789 assert!(
5790 !authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
5791 "a non-admin gains no authority"
5792 );
5793 }
5794
5795 #[tokio::test]
5800 async fn sweep_marks_checked_only_on_an_owner_tombstone() {
5801 use crate::community::migration;
5802 let (bed, owner, stranger) = TestBed::new();
5803
5804 bed.swap_to(&owner);
5805 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5806 let v1_cid = v1.id.to_hex();
5807 v1.owner_attestation = Some({
5808 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5809 .finalize(&owner.keys).unwrap().as_json()
5810 });
5811 crate::db::community::save_community(&v1).unwrap();
5812
5813 let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
5816 let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
5817 bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
5818 crate::db::community::set_community_dissolved(&v1_cid).unwrap();
5819
5820 migration::sweep_dissolved_for_migration(&bed.relay).await;
5822 assert!(
5823 crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
5824 "a stranger-only probe must not converge the sweep"
5825 );
5826
5827 let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
5829 let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
5830 bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
5831 migration::sweep_dissolved_for_migration(&bed.relay).await;
5832 assert!(
5833 !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
5834 "an owner plain-dissolution converges the sweep"
5835 );
5836 }
5837
5838 #[tokio::test]
5840 async fn wizard_preflight_gates_timelock_and_ownership() {
5841 use crate::community::migration;
5842 let (bed, owner, _member) = TestBed::new();
5843 bed.swap_to(&owner);
5844 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5845 v1.owner_attestation = Some({
5846 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
5847 .finalize(&owner.keys).unwrap().as_json()
5848 });
5849 crate::db::community::save_community(&v1).unwrap();
5850
5851 let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
5853 assert!(err.contains("not unlocked"), "{err}");
5854 assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
5855 }
5856
5857 #[tokio::test]
5858 async fn public_link_full_loop() {
5859 let (bed, owner, member) = TestBed::new();
5860
5861 bed.swap_to(&owner);
5862 let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
5863 let general = community.channels[0].id;
5864 send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
5865 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
5867 assert!(link.url.starts_with("https://vectorapp.io/invite/"));
5868 assert!(link.url.contains('#'), "the fragment carries the token");
5869
5870 bed.swap_to(&member);
5872 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
5873 assert_eq!(joined.id().0, community.id().0);
5874 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
5875 }
5876
5877 #[test]
5878 fn bundle_of_snapshots_the_held_icon() {
5879 let owner = Keys::generate();
5880 let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
5881 let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
5882 let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
5883 c.icon = Some(icon.clone());
5884 let bundle = bundle_of(&c, None, None, None);
5885 assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
5886 }
5887
5888 #[test]
5889 fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
5890 let (_tmp, _guard, _owner) = init_test_db();
5895 let cur_root = [9u8; 32];
5896 let cid = crate::community::CommunityId([1u8; 32]);
5897 let cid_hex = cid.to_hex();
5898
5899 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
5901 assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
5902
5903 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
5905 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
5906 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
5907 assert_eq!(roots[0], cur_root, "current root leads");
5908 assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
5909 assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
5910 assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
5912
5913 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
5915 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
5916 assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
5917
5918 for e in 3..20u64 {
5920 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
5921 }
5922 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
5923 assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
5924 }
5925
5926 #[tokio::test]
5927 async fn public_link_preview_shows_live_name_and_icon_without_joining() {
5928 let (bed, owner, member) = TestBed::new();
5929
5930 bed.swap_to(&owner);
5931 let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
5932 let icon = control::ImageRef {
5935 url: "https://blossom.example/soap".into(),
5936 key: "k".into(),
5937 nonce: "n".into(),
5938 hash: "h".into(),
5939 extra: Default::default(),
5940 };
5941 let mut meta = community.metadata();
5942 meta.icon = Some(icon.clone());
5943 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
5944 let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
5946
5947 bed.swap_to(&member);
5949 let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
5950 assert_eq!(preview.name, "Soapbox");
5951 assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
5952 assert!(
5953 crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
5954 "previewing must not persist a membership"
5955 );
5956 }
5957
5958 #[tokio::test]
5959 async fn a_previewed_join_reuses_the_verified_fold() {
5960 let (bed, owner, member) = TestBed::new();
5961 bed.swap_to(&owner);
5962 let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
5963 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
5964
5965 bed.swap_to(&member);
5966 let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
5967 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
5968 assert_eq!(joined.id().0, community.id().0);
5969 assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
5970 assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
5973 }
5974
5975 #[tokio::test]
5976 async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
5977 let (bed, owner, member) = TestBed::new();
5978 bed.swap_to(&owner);
5979 let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
5980 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
5981
5982 bed.swap_to(&member);
5983 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
5984
5985 let session = SessionGuard::capture();
5987 assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the seed folds fresh events");
5988 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
5989 let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
5990 assert!(cursor > 0, "the cursor advanced past zero");
5991 let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
5992 let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
5993 assert_eq!(stored, live, "stored fold == live fold after the seed");
5994 assert!(stored.contains(&member.keys.public_key()));
5995
5996 assert!(sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty());
5998
5999 bed.swap_to(&owner);
6001 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
6002 bed.swap_to(&member);
6003 let session = SessionGuard::capture();
6004 assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the kick lands incrementally");
6005 assert!(
6006 !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
6007 "an owner kick removes the member from the stored fold"
6008 );
6009 }
6010
6011 #[tokio::test]
6012 async fn a_preview_then_revoke_still_refuses_the_join() {
6013 let (bed, owner, member) = TestBed::new();
6014 bed.swap_to(&owner);
6015 let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
6016 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6017
6018 bed.swap_to(&member);
6020 let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
6021 assert_eq!(p.name, "RevokeRace");
6022 bed.swap_to(&owner);
6023 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6024 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
6025
6026 bed.swap_to(&member);
6029 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
6030 assert!(err.contains("revoked"), "got: {err}");
6031 }
6032
6033 #[tokio::test]
6034 async fn a_revoked_link_refuses_to_join() {
6035 let (bed, owner, member) = TestBed::new();
6036 bed.swap_to(&owner);
6037 let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
6038 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6039 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6041 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
6042
6043 bed.swap_to(&member);
6044 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
6045 assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
6046 }
6047
6048 #[tokio::test]
6049 async fn an_expired_direct_invite_refuses_to_join() {
6050 let (bed, owner, member) = TestBed::new();
6051 bed.swap_to(&owner);
6052 let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
6053 let inviter = owner.keys.clone();
6055 let mut bundle = bundle_of(&community, Some(inviter.public_key()), Some(1_000), None);
6056 bundle.expires_at = Some(1_000); let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
6058 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
6059
6060 bed.swap_to(&member);
6061 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6062 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6063 assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
6064 }
6065
6066 #[tokio::test]
6067 async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
6068 let (bed, owner, member) = TestBed::new();
6072 bed.swap_to(&owner);
6073 let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
6074 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6075 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6076
6077 let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
6080
6081 bed.swap_to(&member);
6082 let err = accept_public_link(&union, &link.url).await.unwrap_err();
6083 assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
6084 }
6085
6086 #[test]
6087 fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
6088 let owner = Keys::generate();
6094 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
6095 let hex = crate::simd::hex::bytes_to_hex_32;
6096 let root = [0x11u8; 32];
6097 let mut bundle = CommunityInvite {
6098 community_id: hex(&identity.community_id.0),
6099 owner: hex(&identity.owner_xonly),
6100 owner_salt: hex(&identity.owner_salt),
6101 community_root: hex(&root),
6102 root_epoch: 0,
6103 channels: vec![],
6104 relays: vec!["wss://r".into()],
6105 name: "X".into(),
6106 icon: None,
6107 expires_at: None,
6108 creator_npub: None,
6109 label: None,
6110 extra: Default::default(),
6111 };
6112 bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
6113 .map(|i| {
6114 let mut id = [0u8; 32];
6115 id[..8].copy_from_slice(&(i as u64).to_be_bytes());
6116 invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
6117 })
6118 .collect();
6119 assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
6120 }
6121
6122 #[tokio::test]
6123 async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
6124 let (bed, owner, member) = TestBed::new();
6129 bed.swap_to(&owner);
6130 let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
6131 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6132 let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
6134 swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
6135
6136 bed.swap_to(&member);
6137 let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
6138 assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
6139 assert!(
6140 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6141 "the aborted join wrote nothing to the (member) account DB"
6142 );
6143 }
6144
6145 #[tokio::test]
6146 async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
6147 let (_tmp, _guard, owner) = init_test_db();
6150 let relay = MemoryRelay::new();
6151 let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
6152 let empty = MemoryRelay::new();
6155 let members = memberlist(&empty, &community).await.unwrap();
6156 assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
6157 }
6158
6159 #[tokio::test]
6160 async fn an_expiring_minted_invite_refuses_after_the_deadline() {
6161 let (bed, owner, member) = TestBed::new();
6164 bed.swap_to(&owner);
6165 let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
6166 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
6167 .await
6168 .unwrap();
6169
6170 bed.swap_to(&member);
6171 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6172 assert!(
6173 accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
6174 "a minted expiring invite refuses past its deadline"
6175 );
6176 }
6177
6178 #[tokio::test]
6179 async fn a_member_who_leaves_drops_from_the_memberlist() {
6180 let (bed, owner, member) = TestBed::new();
6181 bed.swap_to(&owner);
6182 let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
6183 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6184
6185 bed.swap_to(&member);
6186 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6187 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6188 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
6190 leave_community(&bed.relay, &joined).await.unwrap();
6191
6192 bed.swap_to(&owner);
6193 let members = memberlist(&bed.relay, &community).await.unwrap();
6194 assert!(members.contains(&owner.keys.public_key()));
6195 assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
6196 }
6197
6198 #[tokio::test]
6199 async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
6200 let (bed, owner, member) = TestBed::new();
6203 bed.swap_to(&owner);
6204 let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
6205 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
6206
6207 bed.swap_to(&member);
6208 assert!(
6209 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6210 "the owner's community must be invisible in the member's account DB"
6211 );
6212 assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
6213 }
6214
6215 async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
6223 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6224 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
6225 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
6226 let mut head: Option<(u64, [u8; 32])> = None;
6227 for w in &wraps {
6228 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
6229 if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
6230 head = Some((ed.version, ed.self_hash));
6231 }
6232 }
6233 }
6234 head.map(|(_, h)| h)
6235 }
6236
6237 async fn cite_on_relay(
6242 relay: &MemoryRelay,
6243 community: &CommunityV2,
6244 signer: &Keys,
6245 ) -> Option<crate::community::edition::AuthorityCitation> {
6246 if community.owner().ok() == Some(signer.public_key()) {
6247 return None;
6248 }
6249 let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
6250 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6251 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
6252 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
6253 let mut head: Option<(u64, [u8; 32])> = None;
6254 for w in &wraps {
6255 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
6256 if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
6257 head = Some((ed.version, ed.self_hash));
6258 }
6259 }
6260 }
6261 head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
6262 }
6263
6264 async fn publish_channel_edition(
6265 relay: &MemoryRelay,
6266 community: &CommunityV2,
6267 signer: &Keys,
6268 channel_id: &ChannelId,
6269 name: &str,
6270 private: bool,
6271 version: u64,
6272 deleted: bool,
6273 ) {
6274 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6275 let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
6276 let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
6277 let content = serde_json::to_string(&meta).unwrap();
6278 let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
6279 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
6280 relay.publish(&wrap, &community.relays).await.unwrap();
6281 }
6282
6283 async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
6286 publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
6287 }
6288
6289 async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
6292 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6293 let prev = head_hash_on_relay(relay, community, &community.id().0).await;
6294 let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
6295 let content = serde_json::to_string(&meta).unwrap();
6296 let cite = cite_on_relay(relay, community, signer).await;
6297 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());
6298 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
6299 relay.publish(&wrap, &community.relays).await.unwrap();
6300 }
6301
6302 #[test]
6303 fn metadata_apply_captures_undriven_fields_for_republish() {
6304 let owner = Keys::generate();
6305 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
6306 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
6307 let general = held.channels[0].id;
6308
6309 let mut custom = serde_json::Map::new();
6311 custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
6312 let mut extra = serde_json::Map::new();
6313 extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
6314 let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
6315 assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
6316 assert_eq!(held.meta_custom, Some(custom.clone()));
6317 assert_eq!(held.meta_extra, extra);
6318 assert_eq!(held.metadata().custom, Some(custom));
6320 assert_eq!(held.metadata().extra, held.meta_extra);
6321
6322 let mut ch_custom = serde_json::Map::new();
6324 ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
6325 let ch_meta = control::ChannelMetadata {
6326 name: "general".into(),
6327 private: false,
6328 voice: Some(true),
6329 deleted: None,
6330 custom: Some(ch_custom.clone()),
6331 extra: Default::default(),
6332 };
6333 assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
6334 let ch = held.channel(&general).unwrap();
6335 assert_eq!(ch.voice, Some(true));
6336 assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
6337 let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
6338 assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
6339 assert_eq!(rename.custom, Some(ch_custom));
6340 }
6341
6342 #[test]
6343 fn community_metadata_apply_sets_and_clears_images() {
6344 let owner = Keys::generate();
6345 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
6346 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
6347
6348 let icon = control::ImageRef {
6349 url: "https://blossom.example/i".into(),
6350 key: "k".into(),
6351 nonce: "n".into(),
6352 hash: "h".into(),
6353 extra: Default::default(),
6354 };
6355 let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
6356 assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
6357 assert_eq!(held.icon.as_ref(), Some(&icon));
6358
6359 let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
6361 assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
6362 assert_eq!(held.icon, None);
6363 }
6364
6365 async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
6367 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6368 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
6369 let prev = head_hash_on_relay(relay, community, &role_id).await;
6370 let content = crate::community::v2::roles::role_content_json(role).unwrap();
6371 let cite = cite_on_relay(relay, community, signer).await;
6372 let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, cite.as_ref());
6373 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
6374 relay.publish(&wrap, &community.relays).await.unwrap();
6375 }
6376
6377 async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
6379 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6380 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
6381 let prev = head_hash_on_relay(relay, community, &eid).await;
6382 let grant = MemberGrant { member: member.to_hex(), role_ids };
6383 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
6384 let cite = cite_on_relay(relay, community, signer).await;
6385 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
6386 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
6387 relay.publish(&wrap, &community.relays).await.unwrap();
6388 }
6389
6390 async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
6392 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6393 let eid = crate::community::v2::derive::banlist_locator(community.id());
6394 let prev = head_hash_on_relay(relay, community, &eid).await;
6395 let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
6396 let cite = cite_on_relay(relay, community, signer).await;
6397 let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
6398 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
6399 relay.publish(&wrap, &community.relays).await.unwrap();
6400 }
6401
6402 fn admin_role(role_id: &str, perms: u64) -> Role {
6403 Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
6404 }
6405
6406 #[tokio::test]
6412 async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
6413 let (bed, owner, attacker) = TestBed::new();
6414 bed.swap_to(&owner);
6415 let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
6416 let victim = Keys::generate().public_key();
6417 grant_admin(&bed.relay, &community, &victim).await.unwrap();
6418
6419 let admin_rid = fetch_authority(&bed.relay, &community)
6421 .await
6422 .roles
6423 .roles
6424 .iter()
6425 .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
6426 .unwrap()
6427 .role_id
6428 .clone();
6429 publish_role(
6431 &bed.relay,
6432 &community,
6433 &attacker.keys,
6434 &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
6435 2,
6436 )
6437 .await;
6438
6439 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
6440 assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
6441 assert!(
6442 authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
6443 "the floor advances only to the AUTHORIZED head (owner v1)"
6444 );
6445 assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
6446 }
6447
6448 #[tokio::test]
6449 async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
6450 let (bed, owner, attacker) = TestBed::new();
6451 bed.swap_to(&owner);
6452 let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
6453 let victim = Keys::generate();
6454 grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
6455
6456 publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
6458
6459 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
6460 assert!(
6461 authority.roles.is_admin(&victim.public_key().to_hex()),
6462 "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
6463 );
6464 }
6465
6466 #[tokio::test]
6467 async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
6468 let (bed, owner, attacker) = TestBed::new();
6469 bed.swap_to(&owner);
6470 let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
6471 let victim = Keys::generate().public_key();
6472 grant_admin(&bed.relay, &community, &victim).await.unwrap();
6473
6474 for i in 0u8..6 {
6477 let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
6478 publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
6479 }
6480
6481 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
6482 assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
6483 assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
6484 }
6485
6486 fn authority_fingerprint(a: &AuthoritySet) -> String {
6489 let mut roles = a.roles.roles.clone();
6490 roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
6491 let mut grants = a.roles.grants.clone();
6492 for g in &mut grants {
6493 g.role_ids.sort();
6494 }
6495 grants.sort_by(|x, y| x.member.cmp(&y.member));
6496 let banned: Vec<&String> = a.banned.iter().collect();
6497 serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
6498 }
6499
6500 #[tokio::test]
6501 async fn the_v2_authority_fold_is_order_independent() {
6502 let (bed, owner, _a) = TestBed::new();
6508 bed.swap_to(&owner);
6509 let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
6510
6511 let admin1 = Keys::generate().public_key();
6514 let admin2 = Keys::generate().public_key();
6515 grant_admin(&bed.relay, &community, &admin1).await.unwrap();
6516 grant_admin(&bed.relay, &community, &admin2).await.unwrap();
6517 let mod_rid = "5c".repeat(32);
6518 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
6519 let member = Keys::generate().public_key();
6520 publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
6521 let banned_member = Keys::generate().public_key();
6522 publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
6523 set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
6524 let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
6525 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
6526 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
6527
6528 let editions = fetch_control(&bed.relay, &community).await;
6529 let floors = load_floors(&community);
6530 assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
6531
6532 let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
6533
6534 let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
6537 let mut rev = editions.clone();
6538 rev.reverse();
6539 orders.push(rev);
6540 for shift in [1usize, 3, 5, 7] {
6541 let n = editions.len();
6542 orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
6543 }
6544 let mut zip = Vec::with_capacity(editions.len());
6546 let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
6547 while lo <= hi {
6548 zip.push(editions[lo as usize].clone());
6549 if lo != hi {
6550 zip.push(editions[hi as usize].clone());
6551 }
6552 lo += 1;
6553 hi -= 1;
6554 }
6555 orders.push(zip);
6556
6557 for (i, order) in orders.iter().enumerate() {
6558 let got = authority_fingerprint(&fold_authority(&community, order, &floors));
6559 assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
6560 }
6561 assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
6564 assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
6565 }
6566
6567 struct FetchErrors(MemoryRelay);
6569 #[async_trait::async_trait]
6570 impl crate::community::transport::Transport for FetchErrors {
6571 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6572 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
6573 self.0.publish(e, r).await
6574 }
6575 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
6576 Err("relay down".to_string())
6577 }
6578 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
6579 self.0.publish_durable(e, r).await
6580 }
6581 }
6582
6583 #[tokio::test]
6584 async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
6585 let (bed, owner, victim) = TestBed::new();
6586 bed.swap_to(&owner);
6587 let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
6588 let victim_hex = victim.keys.public_key().to_hex();
6589 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6591 crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
6592
6593 let down = FetchErrors(MemoryRelay::new());
6596 let view = fetch_authority(&down, &community).await;
6597 assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
6598 }
6599
6600 #[tokio::test]
6601 async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
6602 let (bed, owner, _m) = TestBed::new();
6603 bed.swap_to(&owner);
6604 let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
6605 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6606 let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
6607 let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
6608
6609 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
6611 publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
6612 publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
6613 let session = crate::state::SessionGuard::capture();
6614 follow_control(&bed.relay, &community, &session).await.unwrap();
6615 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
6616
6617 let relay2 = MemoryRelay::new();
6621 publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
6622 follow_control(&relay2, &community, &session).await.unwrap();
6623 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
6624 assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
6625 }
6626
6627 #[tokio::test]
6628 async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
6629 let (_tmp, _guard, owner) = init_test_db();
6634 let relay = MemoryRelay::new();
6635 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
6636 let admin = Keys::generate();
6637 let rid = "a7".repeat(32);
6638 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
6639 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
6640
6641 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6643 let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
6644 let rumor = control::build_edition_rumor(
6645 admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
6646 head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
6647 &serde_json::to_string(&meta).unwrap(), 1_000, None,
6648 );
6649 let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
6650 relay.publish(&wrap, &community.relays).await.unwrap();
6651
6652 let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
6653 let victim = Keys::generate().public_key().to_hex();
6654 let ban_rumor = control::build_edition_rumor(
6655 admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
6656 &serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
6657 );
6658 let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
6659 relay.publish(&ban_wrap, &community.relays).await.unwrap();
6660
6661 let session = SessionGuard::capture();
6662 let updated = follow_control(&relay, &community, &session).await.unwrap();
6663 assert!(
6664 updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
6665 "an uncited metadata edit must not be honored",
6666 );
6667 let authority = fetch_authority(&relay, &community).await;
6668 assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
6669 }
6676
6677 #[tokio::test]
6678 async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
6679 let (_tmp, _guard, owner) = init_test_db();
6683 let relay = MemoryRelay::new();
6684 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
6685 let admin = Keys::generate();
6686 let rid = "a1".repeat(32);
6687 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
6688 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
6689 publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
6690
6691 let session = SessionGuard::capture();
6692 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
6693 assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
6694
6695 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
6697 let _ = follow_control(&relay, &community, &session).await.unwrap();
6698 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6699 assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
6700 }
6701
6702 #[tokio::test]
6703 async fn a_roleless_member_cannot_edit_metadata() {
6704 let (_tmp, _guard, _owner) = init_test_db();
6705 let relay = MemoryRelay::new();
6706 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
6707 let stranger = Keys::generate();
6708 publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
6709 let session = SessionGuard::capture();
6710 assert!(
6711 follow_control(&relay, &community, &session).await.unwrap().is_none(),
6712 "a roleless member's metadata edit never folds"
6713 );
6714 }
6715
6716 #[tokio::test]
6717 async fn a_self_signed_grant_is_not_authority() {
6718 let (_tmp, _guard, _owner) = init_test_db();
6722 let relay = MemoryRelay::new();
6723 let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
6724 let rogue = Keys::generate();
6725 let rid = "b2".repeat(32);
6726 publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
6727 publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
6728 publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
6729 let session = SessionGuard::capture();
6730 assert!(
6731 follow_control(&relay, &community, &session).await.unwrap().is_none(),
6732 "a self-signed grant confers no authority"
6733 );
6734 }
6735
6736 #[tokio::test]
6737 async fn the_banlist_is_enforced_only_from_a_ban_holder() {
6738 let (_tmp, _guard, owner) = init_test_db();
6739 let relay = MemoryRelay::new();
6740 let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
6741 let target = "cc".repeat(32);
6742
6743 let rogue = Keys::generate();
6745 publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
6746 let floors = load_floors(&community);
6747 let editions = fetch_control(&relay, &community).await;
6748 let authority = fold_authority(&community, &editions, &floors);
6749 assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
6750
6751 publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
6753 let editions = fetch_control(&relay, &community).await;
6754 let authority = fold_authority(&community, &editions, &floors);
6755 assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
6756 }
6757
6758 #[tokio::test]
6759 async fn a_banned_admin_loses_all_authority() {
6760 let (_tmp, _guard, owner) = init_test_db();
6763 let relay = MemoryRelay::new();
6764 let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
6765 let admin = Keys::generate();
6766 let rid = "e5".repeat(32);
6767 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
6768 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
6769 publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
6771
6772 let session = SessionGuard::capture();
6773 assert!(
6774 follow_control(&relay, &community, &session).await.unwrap().is_none(),
6775 "a banned admin's edit is dropped even with an unstripped grant"
6776 );
6777 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
6778 assert!(authority.banned.contains(&admin.public_key().to_hex()));
6779 assert!(
6780 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
6781 "a banned admin holds no bit"
6782 );
6783 }
6784
6785 #[tokio::test]
6786 async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
6787 let (_tmp, _guard, owner) = init_test_db();
6791 let relay = MemoryRelay::new();
6792 let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
6793 let admin = Keys::generate();
6794 let moder = Keys::generate();
6795 let stranger = Keys::generate();
6796 let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
6797 publish_role(&relay, &community, &owner, &Role { role_id: admin_rid.clone(), name: "Admin".into(), position: 1, permissions: Permissions(Permissions::ADMIN_ALL), scope: RoleScope::Server, color: 0 }, 1).await;
6798 publish_role(&relay, &community, &owner, &Role { role_id: mod_rid.clone(), name: "Mod".into(), position: 2, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
6799 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
6800 publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
6801 publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
6802
6803 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
6804 assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
6805 assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
6806 assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
6807 }
6808
6809 #[tokio::test]
6810 async fn an_unauthorized_higher_banlist_cannot_unban() {
6811 let (_tmp, _guard, owner) = init_test_db();
6815 let relay = MemoryRelay::new();
6816 let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
6817 let target = "cc".repeat(32);
6818 publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
6819 let session = SessionGuard::capture();
6820 follow_control(&relay, &community, &session).await.unwrap(); let rogue = Keys::generate();
6823 publish_banlist(&relay, &community, &rogue, &[], 2).await; let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
6825 assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
6826 }
6827
6828 #[tokio::test]
6829 async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
6830 let (_tmp, _guard, _owner) = init_test_db();
6833 let relay = MemoryRelay::new();
6834 let relays = vec!["wss://r".to_string()];
6835 let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
6836 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
6837 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
6838
6839 let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
6840 assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
6841 assert_eq!(rehydrated[0].id().0, community.id().0);
6842 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
6843 }
6844
6845 #[tokio::test]
6846 async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
6847 let (_tmp, _guard, _owner) = init_test_db();
6848 let relay = MemoryRelay::new();
6849 let relays = vec!["wss://r".to_string()];
6850 let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
6851 leave_community(&relay, &community).await.unwrap(); let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
6854 assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
6855 }
6856
6857 #[tokio::test]
6858 async fn accepting_the_same_bundle_twice_is_idempotent() {
6859 let (bed, owner, member) = TestBed::new();
6863 bed.swap_to(&owner);
6864 let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
6865 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
6866 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6867 let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
6868
6869 bed.swap_to(&member);
6870 let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
6871 let channels_after_first = first.channels.len();
6872 let root_after_first = first.community_root;
6873
6874 let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
6876 assert_eq!(second.id().0, first.id().0, "same community_id");
6877 assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
6878 assert_eq!(second.community_root, root_after_first, "root unchanged");
6879
6880 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6882 assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
6883 assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
6884 }
6885
6886 #[tokio::test]
6887 async fn a_severed_member_can_be_unbanned_and_re_admitted() {
6888 let (bed, owner, member) = TestBed::new();
6893 bed.swap_to(&owner);
6894 let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
6895 let general = community.channels[0].id;
6896 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6897 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
6898
6899 bed.swap_to(&member);
6900 let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6901 let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
6902 assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
6903
6904 bed.swap_to(&owner);
6906 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
6907 grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
6908 community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
6909 assert_eq!(community.root_epoch, Epoch(1));
6910 send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
6911
6912 bed.swap_to(&member);
6914 let session = SessionGuard::capture();
6915 assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
6916
6917 bed.swap_to(&owner);
6921 set_banlist(&bed.relay, &community, &[]).await.unwrap();
6922 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6923 assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
6924 let fresh_bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
6925
6926 bed.swap_to(&member);
6928 let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
6929 assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
6930 assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
6931 let seen = texts_in(&bed.relay, &rejoined, &general).await;
6932 assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
6933 send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
6934
6935 bed.swap_to(&owner);
6936 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6937 assert!(
6938 texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
6939 "the re-admitted member converses again at the new epoch"
6940 );
6941 let members = memberlist(&bed.relay, &community).await.unwrap();
6943 assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
6944 }
6945
6946 #[tokio::test]
6947 async fn dissolution_blocks_a_join() {
6948 let (bed, owner, member) = TestBed::new();
6951 bed.swap_to(&owner);
6952 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
6953 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
6954 let bundle_json = serde_json::to_string(&bundle).unwrap();
6955 dissolve_community(&bed.relay, &community).await.unwrap();
6956 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
6957
6958 bed.swap_to(&member);
6959 let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
6960 assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
6961 }
6962
6963 #[tokio::test]
6964 async fn only_the_owner_can_dissolve() {
6965 let (bed, owner, member) = TestBed::new();
6966 bed.swap_to(&owner);
6967 let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
6968 bed.swap_to(&member);
6969 assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
6970 assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
6971 }
6972
6973 #[tokio::test]
6974 async fn a_foreign_tombstone_is_not_death() {
6975 let (_tmp, _guard, _owner) = init_test_db();
6978 let relay = MemoryRelay::new();
6979 let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
6980 let rogue = Keys::generate();
6981 let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
6982 let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
6983 relay.publish(&wrap, &community.relays).await.unwrap();
6984 assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
6985 }
6986
6987 #[tokio::test]
6988 async fn a_public_channel_reads_history_across_a_refounding() {
6989 let (_tmp, _guard, _owner) = init_test_db();
6993 let relay = MemoryRelay::new();
6994 let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
6995 let general = community.channels[0].id;
6996 send_message(&relay, &community, &general, "before the refounding").await.unwrap();
6997
6998 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
6999 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
7000 send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
7001
7002 let texts = texts_in(&relay, &refounded, &general).await;
7003 assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
7004 assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
7005 }
7006
7007 #[tokio::test]
7008 async fn refounding_aborts_when_control_state_is_withheld() {
7009 let (_tmp, _guard, owner) = init_test_db();
7013 let relay = MemoryRelay::new();
7014 let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
7015 publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
7016 let session = SessionGuard::capture();
7017 follow_control(&relay, &community, &session).await.unwrap(); let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7022 moved.relays = vec!["wss://empty".into()];
7023 crate::db::community::save_community_v2(&moved).unwrap();
7024
7025 let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
7026 assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
7027 assert_eq!(
7028 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
7029 Epoch(0),
7030 "the epoch did NOT advance (zero published state)"
7031 );
7032 }
7033
7034 #[tokio::test]
7035 async fn refounding_rolls_the_root_and_severs_a_removed_member() {
7036 let (bed, owner, member) = TestBed::new();
7039 bed.swap_to(&owner);
7040 let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
7041 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7042 let bundle_json = serde_json::to_string(&bundle).unwrap();
7043 bed.swap_to(&member);
7044 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7045
7046 bed.swap_to(&owner);
7047 let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
7048 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
7049 assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
7050 assert_eq!(
7052 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
7053 Epoch(1),
7054 "the owner committed the new epoch"
7055 );
7056
7057 bed.swap_to(&member);
7061 let session = SessionGuard::capture();
7062 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
7063 assert!(follow.self_removed, "the removed member is cut by the re-founding");
7064 }
7065
7066 #[tokio::test]
7067 async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
7068 let (bed, owner, member) = TestBed::new();
7073 bed.swap_to(&owner);
7074 let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
7075 let rid = "b0".repeat(32);
7076 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7077 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
7078 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7079 let bundle_json = serde_json::to_string(&bundle).unwrap();
7080 bed.swap_to(&member);
7081 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7082 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7085 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7086 assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
7088 assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
7090 }
7091
7092 #[tokio::test]
7093 async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
7094 let (bed, owner, me) = TestBed::new();
7099 let admin = Keys::generate();
7100 bed.swap_to(&owner);
7101 let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
7102 let rid = "b0".repeat(32);
7103 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7104 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
7105
7106 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7108 let bundle_json = serde_json::to_string(&bundle).unwrap();
7109 bed.swap_to(&me);
7110 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7111 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7112 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7113
7114 let new_root = [0xC7; 32];
7117 publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
7118
7119 let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
7120 .expect("an authorized admin's Refounding is adopted");
7121 assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
7122 assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
7123 }
7124
7125 #[tokio::test]
7126 async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
7127 let (bed, owner, me) = TestBed::new();
7135 bed.swap_to(&owner);
7136 let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
7137 let rid = "b1".repeat(32);
7138 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7139 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
7140
7141 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7142 let bundle_json = serde_json::to_string(&bundle).unwrap();
7143 bed.swap_to(&me);
7144 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7145 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7146 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7147
7148 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
7150 let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
7151 assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
7152
7153 let new_root = [0xD4; 32];
7156 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
7157
7158 let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
7159 .expect("the owner's Refounding is adopted");
7160 assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
7161
7162 let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
7163 assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
7164 assert_eq!(
7165 crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
7166 new_root,
7167 "and the new root behind the same URL",
7168 );
7169 }
7170
7171 #[tokio::test]
7172 async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
7173 let (bed, owner, me) = TestBed::new();
7177 let admin = Keys::generate();
7178 bed.swap_to(&owner);
7179 let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
7180 let rid = "b0".repeat(32);
7181 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7182 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
7183
7184 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7185 let bundle_json = serde_json::to_string(&bundle).unwrap();
7186 bed.swap_to(&me);
7187 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7188 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7189 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7190
7191 publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
7193 let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
7194 assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
7195 }
7196
7197 #[tokio::test]
7198 async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
7199 let (bed, owner, me) = TestBed::new();
7203 let admin_a = Keys::generate();
7204 let admin_b = Keys::generate(); bed.swap_to(&owner);
7206 let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
7207 let rid = "b0".repeat(32);
7208 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7209 publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
7211 publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
7212
7213 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7214 let bundle_json = serde_json::to_string(&bundle).unwrap();
7215 bed.swap_to(&me);
7216 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7217 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7218 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7219
7220 publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
7222
7223 let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
7224 assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
7225 }
7226
7227 #[tokio::test]
7228 async fn a_retried_refounding_reuses_the_same_root() {
7229 let (_tmp, _guard, _owner) = init_test_db();
7232 let relay = MemoryRelay::new();
7233 let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
7234 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7235 let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
7236 let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
7237 assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
7238 }
7239
7240 #[tokio::test]
7241 async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
7242 let (bed, owner, attacker) = TestBed::new();
7249 bed.swap_to(&owner);
7250 let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
7251
7252 let senior = "a1".repeat(32);
7254 let mid = "a5".repeat(32);
7255 publish_role(&bed.relay, &community, &owner.keys,
7256 &Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
7257 publish_role(&bed.relay, &community, &owner.keys,
7258 &Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0 }, 1).await;
7259 publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
7260
7261 publish_role(&bed.relay, &community, &attacker.keys,
7263 &Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 2).await;
7264
7265 let authority = fetch_authority(&bed.relay, &community).await;
7266 let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
7267 assert_eq!(
7268 folded_senior.position, 1,
7269 "a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
7270 );
7271 }
7272
7273 #[tokio::test]
7274 async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
7275 let (bed, owner, admin) = TestBed::new();
7282 bed.swap_to(&owner);
7283 let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
7284 let rid = "c1".repeat(32);
7285 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
7286 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
7287
7288 let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
7290 edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
7291 let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
7292 .filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
7293 .max_by_key(|e| e.version).expect("the owner's metadata edition");
7294 assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
7295
7296 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7299 let bundle_json = serde_json::to_string(&bundle).unwrap();
7300 bed.swap_to(&admin);
7301 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7302 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7303 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7304 set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
7305
7306 let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
7307 .find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
7308 .expect("the admin's banlist edition");
7309 let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
7310 assert_eq!(
7311 cite.entity_id,
7312 crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
7313 "the citation must name the ACTOR'S OWN grant coordinate",
7314 );
7315 assert!(cite.version >= 1, "pinned to a real grant version");
7316 }
7317
7318 #[tokio::test]
7319 async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
7320 let (_tmp, _guard, _owner) = init_test_db();
7326 let relay = MemoryRelay::new();
7327 let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
7328
7329 let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
7330 let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
7331 edit_community_metadata(&relay, &community, &meta).await.unwrap();
7332
7333 let updated = follow_control(&relay, &community, &SessionGuard::capture()).await.unwrap()
7334 .expect("the metadata edition is folded");
7335 assert_eq!(
7336 updated.relays.len(),
7337 crate::community::MAX_COMMUNITY_RELAYS,
7338 "a folded relay list must be truncated, never adopted whole",
7339 );
7340
7341 let again = follow_control(&relay, &updated, &SessionGuard::capture()).await.unwrap();
7345 assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
7346 }
7347
7348 #[tokio::test]
7349 async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
7350 let (bed, owner, me) = TestBed::new();
7356 bed.swap_to(&owner);
7357 let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
7358 let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
7359 let rid = "b2".repeat(32);
7360 publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7361 publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
7362
7363 let bundle = bundle_of(&host, Some(owner.keys.public_key()), None, None);
7364 let bundle_json = serde_json::to_string(&bundle).unwrap();
7365 bed.swap_to(&me);
7366 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7367 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7368 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7369
7370 mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
7372
7373 let before = bed.relay.stored_count();
7374 let new_root = [0xE1; 32];
7375 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
7376 let rotation_events = bed.relay.stored_count() - before;
7377
7378 let after_adopt = bed.relay.stored_count();
7379 follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
7380 assert_eq!(
7381 bed.relay.stored_count(),
7382 after_adopt,
7383 "adopting a rotation must publish NOTHING when I minted no links here",
7384 );
7385 assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
7386 }
7387
7388 #[tokio::test]
7389 async fn an_expired_link_stops_keeping_the_community_public() {
7390 let (_tmp, _guard, _owner) = init_test_db();
7396 let relay = MemoryRelay::new();
7397 let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
7398
7399 let past = now_ms() - 60_000;
7401 mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
7402 assert!(
7403 !community_is_public(&relay, &community).await,
7404 "an already-expired link must never read as a live door",
7405 );
7406
7407 mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
7409 assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
7410 }
7411
7412 #[tokio::test]
7413 async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
7414 let (_tmp, _guard, _owner) = init_test_db();
7417 let relay = MemoryRelay::new();
7418 let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
7419 assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
7420
7421 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
7422 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
7423 let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
7424 assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
7425
7426 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
7427 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
7428 assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
7429 let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
7430 assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
7431 }
7432
7433 #[tokio::test]
7434 async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
7435 let (_tmp, _guard, owner) = init_test_db();
7442 let relay = MemoryRelay::new();
7443 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
7444 mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
7445 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
7446
7447 let cid = community.id();
7448 let control = control_group_key(&community.community_root, cid, community.root_epoch);
7449 let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
7450
7451 let query = Query {
7452 kinds: vec![stream::KIND_WRAP],
7453 authors: vec![control.pk_hex()],
7454 limit: Some(FOLLOW_PAGE),
7455 ..Default::default()
7456 };
7457 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
7458 let target = wraps
7459 .iter()
7460 .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
7461 .filter(|e| e.entity_id == eid)
7462 .max_by_key(|e| e.version)
7463 .expect("the owner published a registry");
7464
7465 let rogue = Keys::generate();
7468 let mut planted = false;
7469 for n in 0..4_000u64 {
7470 let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
7471 let rumor = control::build_edition_rumor(
7472 rogue.public_key(),
7473 vsk::INVITE_LINKS,
7474 &eid,
7475 target.version,
7476 target.prev_hash.as_ref(),
7477 &content,
7478 9_000,
7479 None,
7480 );
7481 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
7482 let (ed, _) = control::open_control_edition(&w, &control).unwrap();
7483 if ed.inner_id < target.inner_id {
7484 relay.publish(&w, &community.relays).await.unwrap();
7485 planted = true;
7486 break;
7487 }
7488 }
7489 assert!(planted, "the test needs a fork that wins the tiebreak");
7490
7491 assert!(
7492 community_is_public(&relay, &community).await,
7493 "an unauthorised fork must not retire the owner's live link"
7494 );
7495 }
7496
7497 #[tokio::test]
7498 async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
7499 let (_tmp, _guard, owner) = init_test_db();
7501 let relay = MemoryRelay::new();
7502 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
7503 let rogue = Keys::generate();
7504 let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
7506 let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
7507 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7508 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
7509 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
7510 relay.publish(&wrap, &community.relays).await.unwrap();
7511 let _ = owner;
7512 assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
7513 }
7514
7515 #[tokio::test]
7516 async fn full_lifecycle_e2e() {
7517 let (bed, owner, member) = TestBed::new();
7523
7524 bed.swap_to(&owner);
7525 let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
7526 let general = community.channels[0].id;
7527 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
7528
7529 let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
7531 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
7532
7533 let rid = "aa".repeat(32);
7535 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7536 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
7537
7538 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7540 let bundle_json = serde_json::to_string(&bundle).unwrap();
7541 bed.swap_to(&member);
7542 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7543 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
7544 publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
7546
7547 bed.swap_to(&owner);
7549 let session = SessionGuard::capture();
7550 let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
7551 assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
7552
7553 set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
7555 grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
7556 let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
7557 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
7558 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
7560 assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
7561 assert!(
7563 texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
7564 "pre-refounding history stays readable"
7565 );
7566
7567 bed.swap_to(&member);
7570 let session = SessionGuard::capture();
7571 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
7572 assert!(follow.self_removed, "the banned member is cryptographically cut");
7573
7574 bed.swap_to(&owner);
7576 dissolve_community(&bed.relay, &refounded).await.unwrap();
7577 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
7578 }
7579
7580 #[tokio::test]
7586 async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
7587 let (bed, owner, member) = TestBed::new();
7592 let attacker = Keys::generate();
7593 bed.swap_to(&owner);
7594 let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
7595 let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
7596 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7597 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
7598 let session = SessionGuard::capture();
7600 follow_control(&bed.relay, &community, &session).await.unwrap();
7601 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
7602
7603 publish_role(&bed.relay, &community, &attacker, &Role { role_id: rid.clone(), name: "pwn".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 }, 2).await;
7605
7606 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
7608 assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
7609
7610 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
7612 assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
7613 }
7614
7615 #[tokio::test]
7616 async fn memberlist_survives_a_refounding_via_the_snapshot() {
7617 let (bed, owner, member) = TestBed::new();
7620 bed.swap_to(&owner);
7621 let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
7622
7623 let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
7625 bed.swap_to(&member);
7626 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
7627 bed.swap_to(&owner);
7628 assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
7629
7630 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
7632 assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
7633
7634 let members = memberlist(&bed.relay, &refounded).await.unwrap();
7636 assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
7637 assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
7638 }
7639
7640 #[tokio::test]
7641 async fn e2e_two_accounts_channels_converse_moderate() {
7642 use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
7643 use nostr_sdk::prelude::ToBech32;
7644 let (bed, a, b) = TestBed::new();
7645 let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
7646 let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
7647 println!("\n===== Concord v2 deep e2e =====");
7648 println!("[acct] A (owner) = {a_npub}");
7649 println!("[acct] B (member) = {b_npub}");
7650
7651 bed.swap_to(&a);
7653 let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
7654 let general = community.channels[0].id;
7655 println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
7656
7657 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
7661 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7662 let priv_ch = community.channel(&priv_id).unwrap();
7663 assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
7664 println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
7665
7666 let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
7668 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7669 let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
7670 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7671 println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
7672 assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
7673
7674 let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
7676 send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
7677 send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
7678 println!("[msg] A posted in #general / #announcements / #mods");
7679
7680 let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
7682 publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
7683 publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
7684 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7685 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
7686 println!("[invite] granted B @admin · minted link {}", link.url);
7687
7688 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.keys.public_key()), None, None)).unwrap();
7689 bed.swap_to(&b);
7690 let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7691 println!("[join] B joined; sees {} channels", b_view.channels.len());
7692 assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
7693 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");
7694 assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
7695 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");
7696 let session_b = SessionGuard::capture();
7699 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
7700 b_view = fresh;
7701 }
7702 println!("[follow] B folded control (roster persisted: B is @admin)");
7703
7704 send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
7706 send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
7707 println!("[msg] B replied in #general + #mods");
7708 let my_pk = b.keys.public_key();
7711 let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
7712 for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
7713 let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
7714 if let Some(o) = outcome { persist_chat(&gh, &o).await; }
7715 }
7716 assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
7717 println!("[persist] #general history persisted into the shared events store");
7718
7719 send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
7721 bed.swap_to(&a);
7722 let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
7723 send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
7724 let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
7725 send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
7726 println!("[ops] reaction + edit + delete round-tripped");
7727
7728 bed.swap_to(&b);
7730 let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
7731 println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
7732 bed.swap_to(&a);
7733 let session = SessionGuard::capture();
7734 if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
7735 community = updated;
7736 }
7737 assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
7738 println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
7739
7740 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
7744 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7745 send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
7746 println!("[channel] +private #vault {} (B is a live member — delivery via rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0));
7747 bed.swap_to(&b);
7748 let session_b2 = SessionGuard::capture();
7749 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
7750 b_view = fresh;
7751 }
7752 let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
7753 assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
7754 let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
7755 b_view = rf.updated.expect("the rekey walk adopts the creation delivery");
7756 let ch = b_view.channel(&vault).expect("still recorded");
7757 assert!(ch.key.is_some() && ch.epoch == Epoch(1), "B adopted the epoch-1 key from the creation crate");
7758 assert!(
7759 texts_in(&bed.relay, &b_view, &vault).await.contains(&"A: vault is open".to_string()),
7760 "B reads the private history with the ADOPTED key"
7761 );
7762 send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.unwrap();
7763 bed.swap_to(&a);
7764 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7765 assert!(
7766 texts_in(&bed.relay, &community, &vault).await.contains(&"B: in the vault".to_string()),
7767 "A reads B's reply on the natively-created private channel"
7768 );
7769 println!("[private] B adopted #vault via rekey plane; two-way private conversation verified");
7770
7771 let members = memberlist(&bed.relay, &community).await.unwrap();
7773 let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
7774 assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
7775 println!("[members] {} members: A + B present", members.len());
7776
7777 set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
7779 grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
7780 let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
7781 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
7782 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
7783 assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
7784 assert!(texts_in(&bed.relay, &refounded, &general).await.iter().any(|t| t == "A: welcome to the deep e2e"), "pre-ban history reads across the new epoch");
7785 assert!(
7786 texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
7787 "PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
7788 );
7789 println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
7790 bed.swap_to(&b);
7792 let session_b3 = SessionGuard::capture();
7793 assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
7794 println!("[ban] B's rekey-follow: self_removed = true (severed)");
7795
7796 bed.swap_to(&a);
7798 set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
7799 let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
7800 assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
7801 println!("[unban] B removed from the banlist (re-invitable)");
7802
7803 dissolve_community(&bed.relay, &refounded).await.unwrap();
7805 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
7806 println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
7807 }
7808
7809 #[tokio::test]
7815 #[ignore]
7816 async fn live_e2e_two_accounts() {
7817 use crate::community::transport::LiveTransport;
7818 use nostr_sdk::prelude::{ClientBuilder, RelayOptions, ToBech32};
7819
7820 let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
7821 let relays = vec![relay.clone()];
7822 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
7823 crate::db::close_database();
7824 crate::db::clear_id_caches();
7825 let tmp = tempfile::tempdir().unwrap();
7826 crate::db::set_app_data_dir(tmp.path().to_path_buf());
7827
7828 let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
7830 let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
7831
7832 let log = |line: String| {
7833 println!("{line}");
7834 if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
7835 use std::io::Write;
7836 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
7837 let _ = writeln!(f, "{line}");
7838 }
7839 }
7840 };
7841 log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
7842 log(format!("VECTOR_E2E_NSEC_A={} ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
7843 log(format!("VECTOR_E2E_NSEC_B={} ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
7844
7845 for k in [&a, &b] {
7846 let npub = k.public_key().to_bech32().unwrap();
7847 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
7848 crate::db::set_current_account(npub.clone()).unwrap();
7849 crate::db::init_database(&npub).unwrap();
7850 }
7851 let client = crate::nostr_client_builder().build();
7854 client.add_managed_relay(relay.as_str()).await.ok();
7855 client.connect().await;
7856 crate::state::set_nostr_client(client);
7857 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
7858 let become_acct = |k: &Keys| {
7859 let npub = k.public_key().to_bech32().unwrap();
7860 crate::db::set_current_account(npub.clone()).unwrap();
7861 crate::db::init_database(&npub).unwrap();
7862 crate::db::clear_id_caches();
7863 crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
7864 crate::state::set_my_public_key(k.public_key());
7865 };
7866 let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
7867
7868 become_acct(&a);
7870 let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
7871 let general = community.channels[0].id;
7872 log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
7873 send_message(&transport, &community, &general, "A: live hello").await.expect("send");
7874 let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
7875 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7876 log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
7877 grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
7878 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
7879 log(format!("[invite] B granted @admin · link {}", link.url));
7880 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.public_key()), None, None)).unwrap();
7881 settle().await;
7882
7883 become_acct(&b);
7885 let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
7886 log(format!("[join] B joined; {} channels", b_view.channels.len()));
7887 settle().await;
7888 let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
7889 let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
7890 log(format!("[read] B sees #general: {seen:?}"));
7891 assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
7892 send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
7893
7894 let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
7898 let hello_id = hello.event.opened().rumor_id.to_hex();
7899 let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7900 let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
7901 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());
7902 let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
7903 transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
7904 log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
7905 settle().await;
7906
7907 become_acct(&a);
7909 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7910 let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
7911 let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
7912 if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
7913 assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
7914 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");
7915 } else {
7916 panic!("the 1111 parsed as a Message");
7917 }
7918 log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
7919 become_acct(&b);
7920 settle().await;
7921
7922 become_acct(&a);
7926 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7927 let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
7928 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7929 send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
7930 log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
7931 settle().await;
7932
7933 become_acct(&b);
7934 let session_b = SessionGuard::capture();
7935 let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
7936 if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
7937 b_view = fresh;
7938 }
7939 if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
7940 b_view = fresh;
7941 }
7942 let vch = b_view.channel(&vault).expect("B folded the vault");
7943 assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
7944 let vseen = texts_in(&transport, &b_view, &vault).await;
7945 log(format!("[read] B sees #vault: {vseen:?}"));
7946 assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
7947 send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
7948 settle().await;
7949
7950 become_acct(&a);
7951 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7952 assert!(
7953 texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
7954 "A reads B's private reply"
7955 );
7956 log("[private] two-way #vault conversation over the live relay".to_string());
7957
7958 set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
7960 grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
7961 let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
7962 log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
7963 settle().await;
7964 dissolve_community(&transport, &refounded).await.expect("dissolve");
7965 log("[dissolve] community sealed".to_string());
7966 log("===== LIVE e2e PASS =====".to_string());
7967 }
7968
7969 #[tokio::test]
7970 async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
7971 let (bed, owner, member) = TestBed::new();
7975 bed.swap_to(&owner);
7976 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
7977 let general = community.channels[0].id;
7978 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
7979
7980 bed.swap_to(&member);
7981 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7982 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
7983
7984 bed.swap_to(&owner);
7986 dissolve_community(&bed.relay, &community).await.unwrap();
7987
7988 bed.swap_to(&member);
7990 let session = SessionGuard::capture();
7991 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
7992 assert!(follow.dissolved, "the catch-up surfaces the tombstone");
7993 assert!(!follow.self_removed && follow.updated.is_none());
7994 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
7995 assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
7996 let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
7997 assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
7998 let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8000 assert!(again.dissolved && again.updated.is_none());
8001 }
8002
8003 #[tokio::test]
8004 async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
8005 const PRIV_CHANNELS: usize = 6;
8010 let (bed, owner, member) = TestBed::new();
8011 bed.swap_to(&owner);
8012 let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
8013 let mut priv_ids = Vec::new();
8014 for i in 0..PRIV_CHANNELS {
8015 let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
8016 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8017 send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
8018 priv_ids.push(id);
8019 }
8020 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
8021
8022 bed.swap_to(&member);
8024 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8025 assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
8026
8027 bed.swap_to(&owner);
8029 for epoch in 1..=2u64 {
8030 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
8031 assert_eq!(community.root_epoch, Epoch(epoch));
8032 for id in &priv_ids {
8033 send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
8034 }
8035 }
8036
8037 bed.swap_to(&member);
8039 let session = SessionGuard::capture();
8040 let mut passes = 0;
8041 loop {
8042 passes += 1;
8043 assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
8044 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8045 let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
8046 assert!(!rk.self_removed);
8047 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8048 let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
8049 if rk.updated.is_none() && ctl.is_none() {
8050 break;
8051 }
8052 }
8053 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8054 assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
8055 for id in &priv_ids {
8057 let mine = caught_up.channel(id).expect("channel survived");
8058 let theirs = community.channel(id).unwrap();
8059 assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
8060 assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
8061 let texts = texts_in(&bed.relay, &caught_up, id).await;
8062 let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
8063 assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
8064 for epoch in 1..=2u64 {
8065 assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
8066 }
8067 }
8068 }
8069
8070 #[tokio::test]
8071 async fn an_offline_member_catches_up_across_three_refoundings() {
8072 use nostr_sdk::prelude::ToBech32;
8077 let (bed, owner, member) = TestBed::new();
8078 bed.swap_to(&owner);
8079 let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
8080 let general = community.channels[0].id;
8081 let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
8082 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8083 send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
8084 send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
8085 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
8086
8087 bed.swap_to(&member);
8089 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8090 assert_eq!(member_view.root_epoch, Epoch(0));
8091
8092 bed.swap_to(&owner);
8094 let stranger = Keys::generate();
8095 for epoch in 1..=3u64 {
8096 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
8097 assert_eq!(community.root_epoch, Epoch(epoch));
8098 send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
8099 send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
8100 }
8101 let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
8102 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8103 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
8104 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8105 send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
8106 set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
8107 let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
8108 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
8109
8110 bed.swap_to(&member);
8113 let session = SessionGuard::capture();
8114 let mut passes = 0;
8115 loop {
8116 passes += 1;
8117 assert!(passes <= 6, "catch-up must converge, not churn");
8118 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8119 let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
8120 assert!(!rekeyed.self_removed, "the member was never removed");
8121 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8122 let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
8123 if rekeyed.updated.is_none() && controlled.is_none() {
8124 break;
8125 }
8126 }
8127 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8128
8129 assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
8131 assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
8132 assert_eq!(caught_up.name, "Sleeper Reborn");
8133 assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
8135 let m = caught_up.channel(&mods).expect("mods survived");
8136 let owner_mods = community.channel(&mods).unwrap();
8137 assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
8138 assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
8139 let v = caught_up.channel(&vault).expect("vault folded in");
8140 assert_eq!(v.key, community.channel(&vault).unwrap().key, "adopted the mid-sleep private channel's key");
8141 let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
8143 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
8144 assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
8145 let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
8148 for epoch in 0..=3u64 {
8149 let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
8150 assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
8151 }
8152 let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
8153 for epoch in 0..=3u64 {
8154 let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
8155 assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
8156 }
8157 assert!(texts_in(&bed.relay, &caught_up, &vault).await.contains(&"epoch3: vault opened".to_string()));
8158 send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
8160 bed.swap_to(&owner);
8161 assert!(
8162 texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
8163 "the caught-up member converses at the new epoch ({})",
8164 member.keys.public_key().to_bech32().unwrap()
8165 );
8166 }
8167
8168 async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
8171 let general = community.channels[0].id;
8172 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
8173 for i in 0..n {
8174 let at = base_secs + i as u64;
8175 let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
8176 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
8177 relay.publish(&wrap, &community.relays).await.unwrap();
8178 }
8179 }
8180
8181 #[tokio::test]
8182 async fn the_history_walk_pages_past_a_multi_page_burst() {
8183 let (_tmp, _guard, owner) = init_test_db();
8186 let relay = MemoryRelay::new();
8187 let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
8188 let general = community.channels[0].id;
8189 flood_general(&relay, &community, &owner, 120, 10_000).await;
8190
8191 let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
8192 assert_eq!(all.len(), 120, "the walk pages the whole burst");
8193 let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
8195 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
8196 assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
8197 let unique: std::collections::HashSet<&String> = contents.iter().collect();
8198 assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
8199
8200 let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
8202 assert_eq!(one.len(), 50, "fetch_channel is one newest page");
8203 assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
8204 }
8205
8206 #[tokio::test]
8207 async fn the_history_walk_stops_when_the_caller_is_caught_up() {
8208 let (_tmp, _guard, owner) = init_test_db();
8209 let relay = MemoryRelay::new();
8210 let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
8211 let general = community.channels[0].id;
8212 flood_general(&relay, &community, &owner, 120, 10_000).await;
8213
8214 let mut pages = 0usize;
8216 let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, crate::community::transport::Evidence::Quorum, |_| {
8217 pages += 1;
8218 false
8219 })
8220 .await
8221 .unwrap();
8222 assert_eq!(pages, 1, "the early stop is consulted once");
8223 assert_eq!(got.len(), 50, "only the newest page is fetched");
8224 assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
8225 }
8226
8227 #[tokio::test]
8228 async fn a_same_second_history_wall_terminates_instead_of_looping() {
8229 let (_tmp, _guard, owner) = init_test_db();
8233 let relay = MemoryRelay::new();
8234 let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
8235 let general = community.channels[0].id;
8236 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
8237 for i in 0..60usize {
8238 let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
8239 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
8240 relay.publish(&wrap, &community.relays).await.unwrap();
8241 }
8242 let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
8243 assert!(got.len() >= 25, "at least the relay page is read");
8244 assert!(got.len() <= 60, "sane bound");
8245 }
8247
8248 #[tokio::test]
8249 async fn a_grant_revoke_survives_a_withholding_relay() {
8250 let (_tmp, _guard, owner) = init_test_db();
8253 let relay = MemoryRelay::new();
8254 let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
8255 let admin = Keys::generate();
8256 let rid = "d4".repeat(32);
8257 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
8258 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
8259 let session = SessionGuard::capture();
8260 follow_control(&relay, &community, &session).await.unwrap(); publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; follow_control(&relay, &community, &session).await.unwrap();
8263
8264 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
8266 let mut stale = community.clone();
8267 stale.relays = vec!["wss://stale".into()];
8268 let floors = load_floors(&community);
8269 let editions = fetch_control(&relay, &stale).await;
8270 let authority = fold_authority(&stale, &editions, &floors);
8271 assert!(
8272 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
8273 "the persisted grant floor refuses the rolled-back (re-granted) view"
8274 );
8275 }
8276
8277 fn load_floors(community: &CommunityV2) -> Floors {
8279 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8280 crate::db::community::get_all_edition_heads_full(&cid_hex)
8281 .unwrap_or_default()
8282 .into_iter()
8283 .filter(|(_, f)| f.0 == community.root_epoch.0)
8284 .map(|(e, f)| (e, (f.1, f.2, f.3)))
8285 .collect()
8286 }
8287
8288 async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
8290 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8291 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
8292 relay
8293 .fetch(&q, &community.relays)
8294 .await
8295 .unwrap_or_default()
8296 .iter()
8297 .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
8298 .collect()
8299 }
8300
8301 #[tokio::test]
8302 async fn follow_control_is_a_noop_on_a_freshly_created_community() {
8303 let (_tmp, _guard, _owner) = init_test_db();
8304 let relay = MemoryRelay::new();
8305 let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
8306 let session = SessionGuard::capture();
8307 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
8309 }
8310
8311 #[tokio::test]
8312 async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
8313 let (_tmp, _guard, owner) = init_test_db();
8314 let relay = MemoryRelay::new();
8315 let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
8316 let new_id = ChannelId([0x5a; 32]);
8317 publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
8318
8319 let session = SessionGuard::capture();
8320 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
8321 assert_eq!(updated.channels.len(), 2);
8322 let added = updated.channel(&new_id).expect("the new channel folded in");
8323 assert_eq!(added.name, "announcements");
8324 assert!(!added.private);
8325 assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
8326
8327 let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
8329 let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
8330 assert!(authors.contains(&addr), "the added channel joins the live subscription");
8331
8332 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8334 assert!(reloaded.channel(&new_id).is_some());
8335 }
8336
8337 #[tokio::test]
8338 async fn follow_control_renames_the_community_and_an_existing_channel() {
8339 let (_tmp, _guard, owner) = init_test_db();
8340 let relay = MemoryRelay::new();
8341 let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
8342 let general = community.channels[0].id;
8343 publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
8345 publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
8346
8347 let session = SessionGuard::capture();
8348 let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
8349 assert_eq!(updated.name, "New Name");
8350 assert_eq!(updated.channel(&general).unwrap().name, "lobby");
8351 assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
8352 }
8353
8354 #[tokio::test]
8355 async fn follow_control_deletes_a_channel() {
8356 let (_tmp, _guard, owner) = init_test_db();
8357 let relay = MemoryRelay::new();
8358 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
8359 let extra = ChannelId([0x77; 32]);
8360 let session = SessionGuard::capture();
8361
8362 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
8364 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
8365 assert!(with_extra.channel(&extra).is_some());
8366
8367 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
8369 let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
8370 assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
8371 assert_eq!(updated.channels.len(), 1, "only #general remains");
8372 }
8373
8374 async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
8378 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8379 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
8380 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
8381 for w in &wraps {
8382 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
8383 if ed.version <= max_version {
8384 relay.inject(w, &[stale_relay.to_string()]);
8385 }
8386 }
8387 }
8388 }
8389
8390 #[tokio::test]
8391 async fn a_withholding_relay_cannot_roll_back_a_rename() {
8392 let (_tmp, _guard, owner) = init_test_db();
8395 let relay = MemoryRelay::new();
8396 let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
8397 publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
8398
8399 let session = SessionGuard::capture();
8400 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
8401 assert_eq!(updated.name, "Renamed");
8402
8403 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
8405 let mut stale_view = updated.clone();
8406 stale_view.relays = vec!["wss://stale".into()];
8407 assert!(
8408 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
8409 "a stale-only relay must not change the held view"
8410 );
8411 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8412 assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
8413 }
8414
8415 #[tokio::test]
8416 async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
8417 let (_tmp, _guard, owner) = init_test_db();
8418 let relay = MemoryRelay::new();
8419 let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
8420 let extra = ChannelId([0x44; 32]);
8421 let session = SessionGuard::capture();
8422
8423 publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
8426 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
8427
8428 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
8429 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
8430 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
8431 let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
8432 assert!(pruned.channel(&extra).is_none());
8433
8434 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
8436 let mut stale_view = pruned.clone();
8437 stale_view.relays = vec!["wss://stale".into()];
8438 assert!(
8439 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
8440 "the withheld delete must not resurrect the channel"
8441 );
8442 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8443 assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
8444 }
8445
8446 #[tokio::test]
8447 async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
8448 let (_tmp, _guard, owner) = init_test_db();
8452 let relay = MemoryRelay::new();
8453 let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
8454 let session = SessionGuard::capture();
8455 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
8456 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
8457 assert_eq!(updated.name, "Edited");
8458
8459 let mut refounded = updated.clone();
8462 refounded.root_epoch = crate::community::Epoch(1);
8463 crate::db::community::save_community_v2(&refounded).unwrap();
8464 publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
8465
8466 let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
8467 assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
8468 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8470 let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
8471 assert!(
8472 heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
8473 "the adopted head carries the fold's epoch + version"
8474 );
8475 }
8476
8477 #[tokio::test]
8478 async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
8479 let (_tmp, _guard, owner) = init_test_db();
8484 let relay = MemoryRelay::new();
8485 let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
8486 let session = SessionGuard::capture();
8487 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8488 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
8489
8490 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
8491 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
8492 assert_eq!(ours.name, "Ours");
8493
8494 let our_inner = {
8496 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
8497 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
8498 wraps
8499 .iter()
8500 .find_map(|w| {
8501 control::open_control_edition(w, &group)
8502 .ok()
8503 .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
8504 .map(|(ed, _)| ed.inner_id)
8505 })
8506 .unwrap()
8507 };
8508
8509 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
8512 let content = serde_json::to_string(&meta).unwrap();
8513 let mut ts = 2_000u64;
8514 let fork_wrap = loop {
8515 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
8516 let inner = rumor.id.unwrap().to_bytes();
8517 if inner < our_inner {
8518 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
8519 }
8520 ts += 1;
8521 };
8522 relay.publish(&fork_wrap, &community.relays).await.unwrap();
8523
8524 let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
8525 assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
8526 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8527 let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
8528 assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
8529 }
8530
8531 #[tokio::test]
8532 async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
8533 let (_tmp, _guard, owner) = init_test_db();
8537 let relay = MemoryRelay::new();
8538 let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
8539 let session = SessionGuard::capture();
8540 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8541
8542 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
8543 let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
8544
8545 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
8547 let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
8548 let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
8549 let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
8550 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
8551 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
8552 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
8553 relay.publish(&w4, &community.relays).await.unwrap();
8554
8555 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
8556 assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
8557
8558 relay.publish(&w3, &community.relays).await.unwrap();
8559 let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
8560 assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
8561 }
8562
8563 #[tokio::test]
8564 async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
8565 let (_tmp, _guard, owner) = init_test_db();
8569 let relay = MemoryRelay::new();
8570 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
8571 let session = SessionGuard::capture();
8572 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8573
8574 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
8575 let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
8576 publish_community_meta(&relay, &base, &owner, "Three", 3).await; let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
8578
8579 let rogue = Keys::generate();
8582 for i in 0..(FOLLOW_PAGE as u64 - 1) {
8583 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
8584 let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
8585 relay.publish(&w, &community.relays).await.unwrap();
8586 }
8587 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
8590 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
8591 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
8592 relay.publish(&w4, &community.relays).await.unwrap();
8593
8594 let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
8595 assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
8596 }
8597
8598 #[tokio::test]
8599 async fn a_follow_after_delete_does_not_resurrect_the_community() {
8600 let (_tmp, _guard, owner) = init_test_db();
8603 let relay = MemoryRelay::new();
8604 let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
8605 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
8606 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8607 crate::db::community::delete_community(&cid_hex).unwrap();
8608
8609 let session = SessionGuard::capture();
8610 assert!(
8611 follow_control(&relay, &community, &session).await.unwrap().is_none(),
8612 "a follow racing a delete is a no-op"
8613 );
8614 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
8615 assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
8616 }
8617
8618 #[tokio::test]
8619 async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
8620 let (_tmp, _guard, owner) = init_test_db();
8623 let relay = MemoryRelay::new();
8624 let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
8625 let new_root = [0xB2; 32];
8626 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
8627 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8628 crate::db::community::delete_community(&cid_hex).unwrap();
8629
8630 let session = SessionGuard::capture();
8631 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
8632 assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
8633 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
8634 }
8635
8636 #[tokio::test]
8637 async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
8638 let (bed, owner, member) = TestBed::new();
8643 bed.swap_to(&owner);
8644 let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
8645 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8646 let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
8647
8648 let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
8650 let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
8651 let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
8652 let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
8653 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
8654 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);
8655 let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
8656 bed.relay.publish(&w3, &community.relays).await.unwrap();
8657
8658 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
8659 let bundle_json = serde_json::to_string(&bundle).unwrap();
8660 bed.swap_to(&member);
8661 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8662 assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
8663 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
8664 let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
8665 assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
8666 }
8667
8668 #[tokio::test]
8669 async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
8670 let (_tmp, _guard, owner) = init_test_db();
8674 let relay = MemoryRelay::new();
8675 let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
8676 let session = SessionGuard::capture();
8677 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8678 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
8679
8680 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
8681 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
8682 assert_eq!(ours.name, "Ours");
8683 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8684 let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
8685 let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
8686
8687 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
8690 let content = serde_json::to_string(&meta).unwrap();
8691 let mut ts = 5_000u64;
8692 let fork_wrap = loop {
8693 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
8694 if rumor.id.unwrap().to_bytes() > our_inner {
8695 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
8696 }
8697 ts += 1;
8698 };
8699 inject_stale_prefix(&relay, &community, 1, "wss://stale").await; relay.inject(&fork_wrap, &["wss://stale".to_string()]);
8701 let mut stale_view = ours.clone();
8702 stale_view.relays = vec!["wss://stale".into()];
8703
8704 assert!(
8705 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
8706 "a losing fork served without our floor edition changes nothing"
8707 );
8708 let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
8709 assert_eq!(held_after, held_before, "the floor row is untouched");
8710 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8711 assert_eq!(held.name, "Ours", "the held state is untouched");
8712 }
8713
8714 #[tokio::test]
8715 async fn follow_control_ignores_a_non_owner_edition() {
8716 let (_tmp, _guard, _owner) = init_test_db();
8720 let relay = MemoryRelay::new();
8721 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
8722 let rogue = Keys::generate();
8723 let rogue_id = ChannelId([0x99; 32]);
8724 publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
8725
8726 let session = SessionGuard::capture();
8727 assert!(
8728 follow_control(&relay, &community, &session).await.unwrap().is_none(),
8729 "a non-owner control edition is not folded"
8730 );
8731 }
8732
8733 #[tokio::test]
8734 async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
8735 let (_tmp, _guard, owner) = init_test_db();
8739 let relay = MemoryRelay::new();
8740 let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
8741 let priv_id = ChannelId([0x33; 32]);
8742 publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
8743
8744 let session = SessionGuard::capture();
8745 let updated = follow_control(&relay, &community, &session)
8746 .await
8747 .unwrap()
8748 .expect("the keyless record is a change");
8749 let ch = updated.channel(&priv_id).expect("the private channel is recorded");
8750 assert!(ch.private && ch.key.is_none(), "recorded keyless");
8751 assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
8752 assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
8753 assert!(
8754 fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
8755 "a keyless fetch returns empty (and never queries the root plane)"
8756 );
8757 assert!(
8758 send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
8759 "a keyless send refuses"
8760 );
8761 let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
8764 let rch = reloaded.channel(&priv_id).unwrap();
8765 assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
8766 let bundle = bundle_of(&reloaded, None, None, None);
8768 assert!(
8769 !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
8770 "an ungrantable keyless channel stays out of invite bundles"
8771 );
8772 }
8773
8774 async fn publish_base_rotation(
8781 relay: &MemoryRelay,
8782 community: &CommunityV2,
8783 rotator: &Keys,
8784 recipients: &[PublicKey],
8785 new_root: &[u8; 32],
8786 prev_key: &[u8; 32],
8787 ) {
8788 let new_epoch = Epoch(community.root_epoch.0 + 1);
8789 let prev_epoch = community.root_epoch;
8790 let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
8791 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
8792 let blobs: Vec<_> = recipients
8793 .iter()
8794 .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
8795 .collect();
8796 let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000, my_authority_citation(community, &rotator.public_key()).as_ref()).unwrap();
8797 for e in &events {
8798 relay.publish(e, &community.relays).await.unwrap();
8799 }
8800 }
8801
8802 fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
8804 community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
8805 crate::db::community::save_community_v2(community).unwrap();
8806 }
8807
8808 #[tokio::test]
8809 async fn follow_rekeys_is_a_noop_without_rotations() {
8810 let (_tmp, _guard, _owner) = init_test_db();
8811 let relay = MemoryRelay::new();
8812 let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
8813 let session = SessionGuard::capture();
8814 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
8815 assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
8816 }
8817
8818 #[tokio::test]
8819 async fn follow_rekeys_adopts_an_owner_base_rotation() {
8820 let (_tmp, _guard, owner) = init_test_db();
8821 let relay = MemoryRelay::new();
8822 let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
8823 let new_root = [0xB1; 32];
8824 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
8826
8827 let session = SessionGuard::capture();
8828 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
8829 assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
8830 assert_eq!(updated.community_root, new_root, "adopted the fresh root");
8831 let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
8833 let general = updated.channels[0].id;
8834 let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
8835 assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
8836 }
8837
8838 #[tokio::test]
8839 async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
8840 let (_tmp, _guard, owner) = init_test_db();
8841 let relay = MemoryRelay::new();
8842 let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
8843 let priv_id = ChannelId([0x33; 32]);
8844 add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
8845
8846 let new_key = [0x55; 32];
8848 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
8849 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
8850 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();
8851 let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None).unwrap();
8852 for e in &events {
8853 relay.publish(e, &community.relays).await.unwrap();
8854 }
8855
8856 let session = SessionGuard::capture();
8857 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
8858 let ch = updated.channel(&priv_id).unwrap();
8859 assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
8860 assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
8861 assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
8862 }
8863
8864 #[tokio::test]
8865 async fn follow_rekeys_ignores_a_non_owner_rotation() {
8866 let (_tmp, _guard, _owner) = init_test_db();
8869 let relay = MemoryRelay::new();
8870 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
8871 let rogue = Keys::generate();
8872 publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
8873
8874 let session = SessionGuard::capture();
8875 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
8876 assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
8877 }
8878
8879 #[tokio::test]
8880 async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
8881 let (_tmp, _guard, owner) = init_test_db();
8884 let relay = MemoryRelay::new();
8885 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
8886 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
8888
8889 let session = SessionGuard::capture();
8890 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
8891 assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
8892 }
8893
8894 #[tokio::test]
8895 async fn follow_rekeys_holds_on_an_incomplete_rotation() {
8896 let (_tmp, _guard, owner) = init_test_db();
8899 let relay = MemoryRelay::new();
8900 let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
8901 let new_epoch = Epoch(1);
8902 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
8903 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
8904 let other = Keys::generate();
8906 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
8907 let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None).unwrap();
8908 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
8909 relay.publish(&wrap, &community.relays).await.unwrap();
8910
8911 let session = SessionGuard::capture();
8912 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
8913 assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
8914 }
8915
8916 #[tokio::test]
8917 async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
8918 let (bed, owner, member) = TestBed::new();
8922 bed.swap_to(&owner);
8923 let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
8924 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
8925
8926 bed.swap_to(&member);
8927 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
8928 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
8929
8930 bed.swap_to(&owner);
8932 let stranger = Keys::generate();
8933 publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
8934
8935 bed.swap_to(&member);
8937 let session = SessionGuard::capture();
8938 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8939 assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
8940 assert!(follow.updated.is_none(), "a removed member adopts nothing");
8941 }
8942
8943 #[tokio::test]
8944 async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
8945 let (_tmp, _guard, owner) = init_test_db();
8951 let relay = MemoryRelay::new();
8952 let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
8953 let root0 = community.community_root;
8954 let priv_id = ChannelId([0x33; 32]);
8955 let key1 = [0x44; 32];
8956 add_private_channel(&mut community, priv_id, key1, Epoch(1));
8957
8958 let key2 = [0x55; 32];
8961 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
8962 let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
8963 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();
8964 for e in rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &prev_commit, &[blob], 2_000, None).unwrap() {
8965 relay.publish(&e, &community.relays).await.unwrap();
8966 }
8967
8968 community.community_root = [0xB7; 32];
8972 community.root_epoch = Epoch(1);
8973 crate::db::community::save_community_v2(&community).unwrap();
8974
8975 let session = SessionGuard::capture();
8976 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
8977 let ch = updated.channel(&priv_id).unwrap();
8978 assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
8979 assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
8980 }
8981
8982 #[tokio::test]
8983 async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
8984 let (_tmp, _guard, owner) = init_test_db();
8989 let relay = MemoryRelay::new();
8990 let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
8991 let priv_id = ChannelId([0x66; 32]);
8992 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() });
8993 crate::db::community::save_community_v2(&community).unwrap();
8994 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8995 assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
8996
8997 let stranger = Keys::generate();
8999 let key1 = [0x71; 32];
9000 let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
9001 let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
9002 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();
9003 for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None).unwrap() {
9004 relay.publish(&e, &community.relays).await.unwrap();
9005 }
9006 let key2 = [0x72; 32];
9009 let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
9010 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
9011 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();
9012 for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None).unwrap() {
9013 relay.publish(&e, &community.relays).await.unwrap();
9014 }
9015
9016 let session = SessionGuard::capture();
9019 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
9020 let ch = updated.channel(&priv_id).unwrap();
9021 assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
9022 assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
9023 }
9024
9025 #[tokio::test]
9026 async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
9027 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
9031 let (_tmp, _guard, _owner) = init_test_db();
9032 let relay = MemoryRelay::new();
9033 let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
9034 let priv_id = ChannelId([0x88; 32]);
9035 let key1 = [0x91; 32];
9036 add_private_channel(&mut community, priv_id, key1, Epoch(1));
9037
9038 let admin = Keys::generate();
9040 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9041 let role = Role::admin("aa".repeat(32));
9042 let roster = CommunityRoles {
9043 roles: vec![role.clone()],
9044 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
9045 };
9046 seed_roster_with_heads(&community, &roster, 1_000);
9047
9048 let key2 = [0x92; 32];
9050 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
9051 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
9052 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
9053 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
9054 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, my_authority_citation(&community, &admin.public_key()).as_ref()).unwrap() {
9055 relay.publish(&e, &community.relays).await.unwrap();
9056 }
9057 let session = SessionGuard::capture();
9058 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
9059 assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
9060
9061 let rogue = Keys::generate();
9063 let key3 = [0x93; 32];
9064 let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
9065 let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
9066 let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
9067 for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None).unwrap() {
9068 relay.publish(&e, &updated.relays).await.unwrap();
9069 }
9070 let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
9071 assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
9072 }
9073
9074 #[tokio::test]
9075 async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
9076 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
9081 let (bed, owner, member) = TestBed::new();
9082 bed.swap_to(&owner);
9083 let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
9084
9085 bed.swap_to(&member);
9088 let mut held = community.clone();
9089 let priv_id = ChannelId([0xAB; 32]);
9090 let key1 = [0xA1; 32];
9091 add_private_channel(&mut held, priv_id, key1, Epoch(1));
9092 let peer = Keys::generate();
9093 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9094 let role = Role::admin("bb".repeat(32));
9095 let roster = CommunityRoles {
9096 roles: vec![role.clone()],
9097 grants: vec![
9098 MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
9099 MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
9100 ],
9101 };
9102 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
9103
9104 let key2 = [0xA2; 32];
9106 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
9107 let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
9108 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();
9109 for e in rekey::build_rekey_chunks_local(&peer, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[pb], 2_000, my_authority_citation(&held, &peer.public_key()).as_ref()).unwrap() {
9110 bed.relay.publish(&e, &held.relays).await.unwrap();
9111 }
9112 let session = SessionGuard::capture();
9113 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
9114 assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
9115 let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
9116 assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
9117
9118 let key3 = [0xA3; 32];
9120 let stranger = Keys::generate();
9121 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();
9122 for e in rekey::build_rekey_chunks_local(&owner.keys, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[ob], 2_100, None).unwrap() {
9123 bed.relay.publish(&e, &held.relays).await.unwrap();
9124 }
9125 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
9126 let updated = follow.updated.expect("the owner's removal folds");
9127 assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
9128 }
9129
9130 #[tokio::test]
9131 async fn converting_a_public_channel_to_private_is_refused() {
9132 let (_tmp, _guard, _owner) = init_test_db();
9135 let relay = MemoryRelay::new();
9136 let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
9137 let general = community.channels[0].id;
9138 let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
9139 let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
9140 assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
9141 let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
9143 edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
9144 }
9145
9146 async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
9149 let doc = super::super::list::CommunityList {
9150 entries: vec![],
9151 tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
9152 extra: Default::default(),
9153 };
9154 let event = super::super::list::build_list_event(me, &doc).unwrap();
9155 relay.publish(&event, relays).await.unwrap();
9156 }
9157
9158 #[tokio::test]
9159 async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
9160 let (_tmp, _guard, me) = init_test_db();
9164 let relay = MemoryRelay::new();
9165 let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
9166 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
9167
9168 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
9170
9171 let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
9173 republish_community_list(&relay, Some(y.id())).await.unwrap();
9174
9175 let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
9177 assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
9178 assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
9179 }
9180
9181 #[tokio::test]
9182 async fn sync_tears_down_a_community_a_sibling_left() {
9183 let (_tmp, _guard, me) = init_test_db();
9186 let relay = MemoryRelay::new();
9187 let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
9188 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
9189 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
9190
9191 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
9192 sync_community_list(&relay, &x.relays).await.unwrap();
9193 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
9194 }
9195
9196 #[tokio::test]
9197 async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
9198 let (_tmp, _guard, me) = init_test_db();
9201 let relay = MemoryRelay::new();
9202 let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
9203 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
9204 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
9206 republish_community_list(&relay, Some(x.id())).await.unwrap();
9208 sync_community_list(&relay, &x.relays).await.unwrap();
9209 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
9210 }
9211
9212 #[tokio::test]
9213 async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
9214 let (_tmp, _guard, _me) = init_test_db();
9217 let good = MemoryRelay::new();
9218 let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
9219 assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
9220
9221 struct FetchErrors;
9223 #[async_trait::async_trait]
9224 impl Transport for FetchErrors {
9225 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
9226 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9227 panic!("republish must NOT publish when the remote fetch failed");
9228 }
9229 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9230 Ok(())
9231 }
9232 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
9233 Err("relay unreachable".to_string())
9234 }
9235 }
9236 republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
9238 }
9239
9240 #[tokio::test]
9241 async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
9242 let (_tmp, _guard, owner) = init_test_db();
9248 let relay = MemoryRelay::new();
9249 let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
9250
9251 let lurker = Keys::generate();
9253 let rid = "b1".repeat(32);
9254 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9255 publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
9256
9257 let members = memberlist(&relay, &community).await.unwrap();
9259 assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
9260
9261 let banned_grantee = Keys::generate();
9263 publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
9264 set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
9265 let members = memberlist(&relay, &community).await.unwrap();
9266 assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
9267 assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
9268
9269 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
9271 assert_eq!(refounded.root_epoch, Epoch(1));
9272 let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
9273 let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
9274 let rotations = rekey::collect_rotations(&chunks);
9275 let lurker_x = lurker.public_key().to_bytes();
9276 let delivered = rotations.iter().any(|r| {
9277 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
9278 });
9279 assert!(delivered, "the Refounding delivered the new root to the granted lurker");
9280 }
9281
9282 #[tokio::test]
9283 async fn the_memberlist_pages_past_a_guestbook_flood() {
9284 let (_tmp, _guard, _owner) = init_test_db();
9288 let relay = MemoryRelay::new();
9289 let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
9290 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
9291
9292 let honest = Keys::generate();
9294 let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
9295 let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
9296 relay.publish(&w, &community.relays).await.unwrap();
9297 for i in 0..600u64 {
9298 let throwaway = Keys::generate();
9299 let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
9300 let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
9301 relay.publish(&w, &community.relays).await.unwrap();
9302 }
9303
9304 let members = memberlist(&relay, &community).await.unwrap();
9305 assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
9306 }
9307
9308 #[tokio::test]
9309 async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
9310 let (_tmp, _guard, owner) = init_test_db();
9315 let relay = MemoryRelay::new();
9316 let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
9317 let new_root = [0xD9; 32];
9318 let new_epoch = Epoch(1);
9319 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
9320
9321 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
9323
9324 let rogue = Keys::generate();
9327 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
9328 for i in 0..260u64 {
9329 let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
9330 let rumor = rekey::build_rekey_rumor(rogue.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 1, 3_000 + i, None).unwrap();
9331 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
9332 relay.publish(&wrap, &community.relays).await.unwrap();
9333 }
9334
9335 let session = SessionGuard::capture();
9336 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
9337 assert_eq!(updated.root_epoch, Epoch(1));
9338 assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
9339 }
9340
9341 #[tokio::test]
9342 async fn a_swap_during_create_private_channel_aborts_without_a_write() {
9343 let (bed, owner, _member) = TestBed::new();
9348 bed.swap_to(&owner);
9349 let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
9350 let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
9351
9352 let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
9354 let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
9355 assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
9356 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9357 assert_eq!(after.channels.len(), before, "no channel row was written");
9358 assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
9359 }
9360
9361 #[tokio::test]
9362 async fn an_uncited_admin_rotation_is_not_adopted() {
9363 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
9368 let (_tmp, _guard, _owner) = init_test_db();
9369 let relay = MemoryRelay::new();
9370 let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
9371 let priv_id = ChannelId([0x8A; 32]);
9372 let key1 = [0x93; 32];
9373 add_private_channel(&mut community, priv_id, key1, Epoch(1));
9374
9375 let admin = Keys::generate();
9376 let role = Role::admin("cf".repeat(32));
9377 let roster = CommunityRoles {
9378 roles: vec![role.clone()],
9379 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
9380 };
9381 seed_roster_with_heads(&community, &roster, 1_000);
9382
9383 let key2 = [0x94; 32];
9384 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
9385 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
9386 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
9387 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
9388 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None).unwrap() {
9390 relay.publish(&e, &community.relays).await.unwrap();
9391 }
9392
9393 let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
9394 assert!(out.updated.is_none(), "an uncited rotation is not adopted");
9395
9396 let cited = my_authority_citation(&community, &admin.public_key());
9399 assert!(cited.is_some(), "the seeded head yields a citation");
9400 let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
9401 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob2], 2_100, cited.as_ref()).unwrap() {
9402 relay.publish(&e, &community.relays).await.unwrap();
9403 }
9404 let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
9405 assert!(out.updated.is_some(), "the cited rotation IS adopted");
9406 }
9407
9408 #[tokio::test]
9409 async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
9410 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
9418 let (_tmp, _guard, _owner) = init_test_db();
9419 let relay = MemoryRelay::new();
9420 let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
9421 let priv_id = ChannelId([0xC0; 32]);
9422 let key1 = [0xC1; 32];
9423 add_private_channel(&mut community, priv_id, key1, Epoch(1));
9424
9425 let (a, b) = (Keys::generate(), Keys::generate());
9427 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9428 let role = Role::admin("ce".repeat(32));
9429 let roster = CommunityRoles {
9430 roles: vec![role.clone()],
9431 grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
9432 };
9433 seed_roster_with_heads(&community, &roster, 1_000);
9434
9435 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
9438 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
9439 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
9440 let key_a = [0x0A; 32];
9441 let key_b = [0xFB; 32]; for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
9443 let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
9444 for e in rekey::build_rekey_chunks_local(signer, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, my_authority_citation(&community, &signer.public_key()).as_ref()).unwrap() {
9445 relay.publish(&e, &community.relays).await.unwrap();
9446 }
9447 }
9448
9449 let session = SessionGuard::capture();
9450 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
9451 let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
9452 assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
9453
9454 let mut peer = community.clone();
9456 if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
9457 c.key = Some(key1);
9458 c.epoch = Epoch(1);
9459 }
9460 let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
9462 assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
9463 }
9464
9465 #[tokio::test]
9466 async fn create_private_channel_refuses_a_member_without_manage_channels() {
9467 let (bed, owner, member) = TestBed::new();
9470 bed.swap_to(&owner);
9471 let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
9472 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
9473
9474 bed.swap_to(&member);
9475 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9476 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
9477 let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
9478 assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
9479 let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
9480 assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
9481 }
9482
9483 #[tokio::test]
9486 async fn accept_rejects_a_bundle_with_a_forged_community_root() {
9487 let (bed, owner, member) = TestBed::new();
9491 bed.swap_to(&owner);
9492 let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
9493
9494 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
9495 let mut forged = bundle_of(&community, None, None, None);
9496 forged.community_root = fake.clone();
9497 for ch in &mut forged.channels {
9498 ch.key = fake.clone();
9499 }
9500 let attacker = Keys::generate();
9501 let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
9502 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
9503
9504 bed.swap_to(&member);
9505 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9506 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
9507 assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
9508 assert!(
9509 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
9510 "a rejected join persists nothing"
9511 );
9512 }
9513
9514 #[tokio::test]
9515 async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
9516 let (bed, owner, member) = TestBed::new();
9521 bed.swap_to(&owner);
9522 let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
9523 let general = community.channels[0].id;
9524
9525 let mut rotated = community.clone();
9526 rotated.community_root = [0x5A; 32];
9527 rotated.root_epoch = Epoch(1);
9528 let admin = Keys::generate();
9529 publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
9530 publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
9531
9532 bed.swap_to(&member);
9533 let bundle = bundle_of(&rotated, None, None, None);
9534 let session = SessionGuard::capture();
9535 let joined = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
9536 assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
9537 }
9538
9539 #[tokio::test]
9540 async fn only_an_actual_join_publishes_a_guestbook_join() {
9541 let (bed, owner, member) = TestBed::new();
9545 bed.swap_to(&owner);
9546 let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
9547
9548 let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
9549 async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
9550 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
9551 relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
9552 }
9553 let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await; bed.swap_to(&member);
9556 let bundle = bundle_of(&community, None, None, None);
9557 let session = SessionGuard::capture();
9558 accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
9559 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
9560
9561 accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
9562 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
9563
9564 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9565 crate::db::community::delete_community(&cid_hex).unwrap();
9566 accept_bundle(&bed.relay, &session, &bundle, None, false).await.unwrap();
9567 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
9568 }
9569
9570 #[tokio::test]
9571 async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
9572 let (bed, owner, member) = TestBed::new();
9575 bed.swap_to(&owner);
9576 let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
9577
9578 let mut rotated = community.clone();
9579 rotated.community_root = [0x5B; 32];
9580 rotated.root_epoch = Epoch(1);
9581 let attacker = Keys::generate();
9582 publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
9583
9584 bed.swap_to(&member);
9585 let bundle = bundle_of(&rotated, None, None, None);
9586 let session = SessionGuard::capture();
9587 let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
9588 assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
9589 }
9590
9591 #[tokio::test]
9592 async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
9593 let (bed, owner, member) = TestBed::new();
9596 bed.swap_to(&owner);
9597 let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
9598 let general = community.channels[0].id;
9599
9600 let mut fake = community.clone();
9601 fake.community_root = [0x5C; 32]; let admin = Keys::generate();
9603 publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
9604 publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
9605
9606 bed.swap_to(&member);
9607 let bundle = bundle_of(&fake, None, None, None);
9608 let session = SessionGuard::capture();
9609 let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
9610 assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
9611 }
9612
9613 #[tokio::test]
9614 async fn follow_control_heals_a_bundle_misclassified_public_channel() {
9615 let (_tmp, _guard, _owner) = init_test_db();
9619 let relay = MemoryRelay::new();
9620 let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
9621 let general = community.channels[0].id;
9622 let mut poisoned = community.clone();
9623 poisoned.channels[0].private = true;
9624 poisoned.channels[0].key = Some([0x66; 32]);
9625 crate::db::community::save_community_v2(&poisoned).unwrap();
9626
9627 let session = SessionGuard::capture();
9628 let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
9629 let ch = healed.channel(&general).unwrap();
9630 assert!(!ch.private, "the owner's public declaration overrides the bundle");
9631 assert_eq!(ch.key, None, "a healed public channel derives from the root");
9632 }
9633
9634 #[tokio::test]
9635 async fn a_deleted_channel_does_not_resurrect_on_reload() {
9636 let (_tmp, _guard, owner) = init_test_db();
9639 let relay = MemoryRelay::new();
9640 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
9641 let extra = ChannelId([0x77; 32]);
9642 let session = SessionGuard::capture();
9643 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
9644 let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
9645 assert!(with_extra.channel(&extra).is_some());
9646 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
9647 let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
9648 assert!(after.channel(&extra).is_none());
9649
9650 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9651 assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
9652 assert_eq!(reloaded.channels.len(), 1);
9653 }
9654
9655 #[tokio::test]
9656 async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
9657 let (_tmp, _guard, _owner) = init_test_db();
9661 let relay = MemoryRelay::new();
9662 let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
9663 let a_channel = a.channels[0].id;
9664 let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
9665 let b_channel = b.channels[0].id;
9666 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() });
9668
9669 crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
9670 let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
9672 assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
9673 assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
9674 let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
9676 assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
9677 assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
9678 }
9679
9680 struct CappedRelay {
9686 events: Vec<Event>,
9687 cap: usize,
9688 }
9689 #[async_trait::async_trait]
9690 impl Transport for CappedRelay {
9691 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
9692 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9693 Ok(())
9694 }
9695 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9696 Ok(())
9697 }
9698 async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
9699 let mut m: Vec<Event> = self
9700 .events
9701 .iter()
9702 .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
9703 .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
9704 .cloned()
9705 .collect();
9706 m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
9708 Ok(m)
9709 }
9710 }
9711
9712 #[tokio::test]
9713 async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
9714 let (_tmp, _guard, _owner) = init_test_db();
9721 let memory = MemoryRelay::new();
9722 let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
9723 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
9724
9725 let rogue = Keys::generate();
9726 let mut events: Vec<Event> = Vec::new();
9727 for i in 0..FOLLOW_PAGE {
9728 let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
9729 let rumor = control::build_edition_rumor(
9730 rogue.public_key(),
9731 vsk::CHANNEL_METADATA,
9732 &[0xAB; 32],
9733 1,
9734 None,
9735 &content,
9736 9_000,
9737 None,
9738 );
9739 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
9740 events.push(w);
9741 }
9742 let relay = CappedRelay { events, cap: FOLLOW_PAGE };
9743
9744 let err = refound_community(&relay, &community, &[])
9745 .await
9746 .expect_err("a plane that can't be read whole must never be compacted");
9747 assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
9748 }
9749
9750 #[tokio::test]
9751 async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
9752 let (_tmp, _guard, owner) = init_test_db();
9757 let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
9758 let g = control::genesis(&owner, meta, 1_000).unwrap();
9759 let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
9760
9761 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
9762 let rogue = Keys::generate();
9763 let mut events: Vec<Event> = g.wraps.to_vec();
9764 for i in 0..250u64 {
9765 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
9766 let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
9767 events.push(wrap);
9768 }
9769 let relay = CappedRelay { events, cap: 100 };
9771 let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
9772 assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
9773 }
9774
9775 #[tokio::test]
9776 async fn accept_parked_invite_joins_from_the_stored_bundle() {
9777 let (bed, owner, member) = TestBed::new();
9780 bed.swap_to(&owner);
9781 let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
9782 let general = community.channels[0].id;
9783 send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
9784 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
9785 let bundle_json = serde_json::to_string(&bundle).unwrap();
9786 let inviter_hex = owner.keys.public_key().to_hex();
9787
9788 bed.swap_to(&member);
9789 let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
9790 assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
9791 assert!(joined.identity.verify());
9792 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
9793 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
9796 assert!(
9797 crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
9798 "the joiner's control floor is seeded from the join-time fold"
9799 );
9800
9801 bed.swap_to(&owner);
9803 let members = memberlist(&bed.relay, &community).await.unwrap();
9804 assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
9805 }
9806
9807 #[tokio::test]
9808 async fn accept_parked_invite_rejects_a_forged_root() {
9809 let (_tmp, _guard, _owner) = init_test_db();
9813 let relay = MemoryRelay::new();
9814 let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
9815 let mut forged = bundle_of(&community, None, None, None);
9816 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
9817 forged.community_root = fake.clone();
9818 for ch in &mut forged.channels {
9819 ch.key = fake.clone();
9820 }
9821 let bundle_json = serde_json::to_string(&forged).unwrap();
9822
9823 let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
9824 assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
9825 }
9826
9827 #[test]
9828 fn v2_and_v1_bundles_are_distinguishable_by_parse() {
9829 let owner = Keys::generate();
9833 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
9834 let hex = crate::simd::hex::bytes_to_hex_32;
9835 let v2 = invite::CommunityInvite {
9836 community_id: hex(&identity.community_id.0),
9837 owner: hex(&identity.owner_xonly),
9838 owner_salt: hex(&identity.owner_salt),
9839 community_root: hex(&[0x11; 32]),
9840 root_epoch: 0,
9841 channels: vec![],
9842 relays: vec!["wss://r".into()],
9843 name: "V2".into(),
9844 icon: None,
9845 expires_at: None,
9846 creator_npub: None,
9847 label: None,
9848 extra: Default::default(),
9849 };
9850 let v2_json = serde_json::to_string(&v2).unwrap();
9851 assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
9852 let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
9853 assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
9854 }
9855
9856 #[tokio::test]
9857 async fn verify_rejects_a_cross_community_owner_edition_replay() {
9858 let (_tmp, _guard, owner) = init_test_db();
9864
9865 let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
9867 let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
9868 let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
9869
9870 let t_identity = control::CommunityIdentity::mint(&owner.public_key());
9872 let fake_root = [0xEE; 32];
9873 let t = CommunityV2 {
9874 identity: t_identity,
9875 community_root: fake_root,
9876 root_epoch: Epoch(0),
9877 name: "T".into(),
9878 description: None,
9879 icon: None,
9880 banner: None,
9881 meta_custom: None,
9882 meta_extra: Default::default(),
9883 relays: vec!["wss://r".into()],
9884 channels: vec![],
9885 dissolved: false,
9886 created_at_ms: 0,
9887 };
9888 let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
9891 let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
9892 let relay = MemoryRelay::new();
9893 relay.publish(&replayed, &t.relays).await.unwrap();
9894
9895 let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
9896 assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
9897 }
9898
9899 #[tokio::test]
9907 #[ignore = "hits a real relay over the network"]
9908 async fn live_smoke_create_send_fetch_on_a_real_relay() {
9909 use crate::community::transport::LiveTransport;
9910 use nostr_sdk::prelude::ToBech32;
9911
9912 let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
9913 let relays = vec![relay.clone()];
9914
9915 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
9917 crate::db::close_database();
9918 crate::db::clear_id_caches();
9919 let tmp = tempfile::tempdir().unwrap();
9920 let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
9923 Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
9924 Err(_) => Keys::generate(),
9925 };
9926 let npub = keys.public_key().to_bech32().unwrap();
9927 if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
9930 println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
9931 }
9932 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
9933 crate::db::set_app_data_dir(tmp.path().to_path_buf());
9934 crate::db::set_current_account(npub.clone()).unwrap();
9935 crate::db::init_database(&npub).unwrap();
9936 crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
9937 crate::state::set_my_public_key(keys.public_key());
9938 println!("[smoke] throwaway identity {npub}");
9939
9940 let client = crate::nostr_client_builder().build();
9942 client.add_managed_relay(relay.as_str()).await.ok();
9943 client.connect().await;
9944 crate::state::set_nostr_client(client);
9945 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
9946
9947 let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
9949 let general = community.channels[0].id;
9950 println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
9951
9952 let text = "hello from a Vector Concord v2 live smoke test";
9953 let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
9954 println!("[smoke] sent message {sent_id}");
9955
9956 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
9958
9959 let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
9960 let texts: Vec<String> = page
9961 .iter()
9962 .filter_map(|f| match &f.event {
9963 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
9964 _ => None,
9965 })
9966 .collect();
9967 println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
9968 assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
9969
9970 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
9972 println!("[smoke] invite link: {}", link.url);
9973 println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
9974 }
9975
9976 #[tokio::test]
9977 async fn chat_ops_react_edit_delete_round_trip() {
9978 let (bed, owner, _member) = TestBed::new();
9979 bed.swap_to(&owner);
9980 let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
9981 let general = community.channels[0].id;
9982 let me_hex = owner.keys.public_key().to_hex();
9983
9984 let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
9985 send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
9986 .await
9987 .unwrap();
9988 send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
9989 send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
9990
9991 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
9992 let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
9993 let mut saw = (false, false, false);
9994 for f in &page {
9995 match &f.event {
9996 ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
9997 assert_eq!(emoji, ":fire:");
9998 assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
9999 saw.0 = true;
10000 }
10001 ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
10002 assert_eq!(new_content, "edited");
10003 saw.1 = true;
10004 }
10005 ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
10006 _ => {}
10007 }
10008 }
10009 assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
10010 }
10011
10012 #[tokio::test]
10013 async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
10014 let (bed, owner, _member) = TestBed::new();
10015 bed.swap_to(&owner);
10016 let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
10017 let general = community.channels[0].id;
10018 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
10019
10020 let mut sub = bed.relay.subscribe(Query {
10022 kinds: vec![stream::KIND_WRAP_EPHEMERAL],
10023 authors: vec![group.pk_hex()],
10024 ..Default::default()
10025 });
10026 send_typing(&bed.relay, &community, &general).await.unwrap();
10027 let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
10028 let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
10029 Ok(ChatEvent::Typing { opened }) => opened,
10030 other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
10031 };
10032
10033 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
10036 assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
10037
10038 assert!(
10040 crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
10041 "ephemeral sends must not retain scrub keys"
10042 );
10043 }
10044
10045 #[tokio::test]
10046 async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
10047 let (bed, owner, _member) = TestBed::new();
10048 bed.swap_to(&owner);
10049 let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
10050 let general = community.channels[0].id;
10051 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
10052
10053 let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
10054
10055 let (keys, outer_hex, relays) =
10058 crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
10059 assert_eq!(relays, community.relays);
10060 let wrap_query = Query {
10061 kinds: vec![stream::KIND_WRAP],
10062 authors: vec![group.pk_hex()],
10063 ..Default::default()
10064 };
10065 let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
10066 let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
10067 assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
10068
10069 let me_hex = owner.keys.public_key().to_hex();
10071 let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
10072 .await
10073 .unwrap();
10074 assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
10075
10076 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
10079 assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
10080 let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
10081 assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
10082 }
10083
10084 #[tokio::test]
10085 async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
10086 let (bed, owner, _member) = TestBed::new();
10087 bed.swap_to(&owner);
10088 let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
10089 let general = community.channels[0].id;
10090 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
10091
10092 let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
10095 crate::db::community::delete_message_key(&id).unwrap();
10096 assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
10097
10098 let mkeys = Keys::generate();
10100 let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
10101 let foreign_id = rumor.id.unwrap().to_hex();
10102 let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
10103 bed.relay.publish(&fw, &community.relays).await.unwrap();
10104
10105 fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
10107 let (keys, _outer, relays) =
10108 crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
10109 assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
10110 assert_eq!(relays, community.relays);
10111
10112 assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
10114
10115 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
10117 let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
10118 assert!(
10119 !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
10120 "healed message scrubbed from the relay"
10121 );
10122 }
10123
10124 #[tokio::test]
10125 async fn send_chat_message_threads_the_reply_and_extra_tags() {
10126 let (bed, owner, _member) = TestBed::new();
10127 bed.swap_to(&owner);
10128 let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
10129 let general = community.channels[0].id;
10130 let me_hex = owner.keys.public_key().to_hex();
10131
10132 let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
10133 let imeta = nostr_sdk::prelude::Tag::custom(
10134 "imeta",
10135 ["url https://e/blob".to_string(), "m image/png".to_string()],
10136 );
10137 let child_id = send_chat_message(
10138 &bed.relay, &community, &general, "child",
10139 Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
10140 )
10141 .await
10142 .unwrap();
10143
10144 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
10145 let child = page
10146 .iter()
10147 .find_map(|f| match &f.event {
10148 ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
10149 _ => None,
10150 })
10151 .expect("the reply message round-trips");
10152 let reply = child.1.as_ref().expect("the reply reference is carried");
10153 assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
10154 assert_eq!(reply.author, Some(owner.keys.public_key()));
10155 assert!(
10156 child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
10157 "the imeta attachment tag rides the rumor verbatim"
10158 );
10159 }
10160
10161 #[tokio::test]
10162 async fn a_kick_needs_kick_authority_and_removes_the_target() {
10163 let (bed, owner, member) = TestBed::new();
10164 bed.swap_to(&owner);
10165 let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
10166
10167 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
10169 let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
10170 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
10171 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
10172 let before = memberlist(&bed.relay, &community).await.unwrap();
10173 assert!(before.contains(&member.keys.public_key()), "the join lands first");
10174
10175 bed.swap_to(&member);
10177 let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
10178 assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
10179
10180 bed.swap_to(&owner);
10182 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
10183 let after = memberlist(&bed.relay, &community).await.unwrap();
10184 assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
10185 assert!(after.contains(&owner.keys.public_key()), "the owner remains");
10186 }
10187
10188 #[tokio::test]
10189 async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
10190 let (bed, owner, member) = TestBed::new();
10195 bed.swap_to(&owner);
10196 let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
10197 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10198 let (o, m) = (owner.keys.public_key(), member.keys.public_key());
10199 let join = |at: u64, id: u8| guestbook::GuestbookEvent {
10200 rumor_id: [id; 32],
10201 entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
10202 };
10203 let kick = |at: u64, id: u8| guestbook::GuestbookEvent {
10204 rumor_id: [id; 32],
10205 entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
10206 };
10207
10208 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
10210 assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
10211
10212 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
10214 assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
10215
10216 crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
10218 assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
10219
10220 assert!(
10223 !stored_memberlist(&community).unwrap().contains(&m),
10224 "the memberlist excludes an un-caught-up member — why it can't gate a kick"
10225 );
10226 }
10227
10228 fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
10233 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10234 crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
10235 for g in &roster.grants {
10236 let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
10237 let eid = super::super::derive::grant_locator(community.id(), &m);
10238 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
10239 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
10240 }
10241 }
10242
10243 async fn publish_grant_citing(
10245 relay: &MemoryRelay,
10246 community: &CommunityV2,
10247 signer: &Keys,
10248 member: &PublicKey,
10249 role_ids: Vec<String>,
10250 version: u64,
10251 citation: Option<&crate::community::edition::AuthorityCitation>,
10252 ) {
10253 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10254 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
10255 let prev = head_hash_on_relay(relay, community, &eid).await;
10256 let grant = MemberGrant { member: member.to_hex(), role_ids };
10257 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
10258 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
10259 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
10260 relay.publish(&wrap, &community.relays).await.unwrap();
10261 }
10262
10263 #[tokio::test]
10264 async fn an_uncited_admin_edition_is_not_folded_but_a_cited_one_is() {
10265 let (bed, owner, admin) = TestBed::new();
10270 bed.swap_to(&owner);
10271 let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
10272 let admin_pk = admin.keys.public_key();
10273 let rid = "c3".repeat(32);
10274 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
10275 publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
10276
10277 let low_rid = "c4".repeat(32);
10281 let mut low = admin_role(&low_rid, Permissions::admin().0);
10282 low.position = 5;
10283 publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
10284
10285 let bystander = Keys::generate().public_key();
10286 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
10287 let view = fetch_authority(&bed.relay, &community).await;
10288 assert!(
10289 !view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
10290 "an uncited non-owner edition is not folded"
10291 );
10292 assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant folds");
10294
10295 let _ = follow_control(&bed.relay, &community, &SessionGuard::capture()).await;
10298 let entity_id = crate::community::v2::derive::grant_locator(community.id(), &admin_pk.to_bytes());
10299 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10300 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
10301 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
10302 let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
10303 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid], 2, Some(&cite)).await;
10304
10305 let view = fetch_authority(&bed.relay, &community).await;
10306 assert!(
10307 view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
10308 "the same edition WITH its synced citation folds"
10309 );
10310 }
10311
10312 #[tokio::test]
10313 async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
10314 let (bed, owner, member) = TestBed::new();
10319 bed.swap_to(&owner);
10320 let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
10321 let member_pk = member.keys.public_key();
10322 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10323
10324 crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
10326 crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
10327
10328 let join = guestbook::GuestbookEvent {
10330 rumor_id: [9u8; 32],
10331 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
10332 };
10333 assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
10334 assert!(
10335 !stored_memberlist(&community).unwrap().contains(&member_pk),
10336 "while banned, the fold keeps them out"
10337 );
10338
10339 crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
10341 assert!(
10342 stored_memberlist(&community).unwrap().contains(&member_pk),
10343 "after the unban the raced Join makes them a member again"
10344 );
10345 }
10346
10347 #[tokio::test]
10348 async fn a_stale_root_admin_write_is_refused_not_misdirected() {
10349 let (bed, owner, member) = TestBed::new();
10355 bed.swap_to(&owner);
10356 let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
10357 let member_pk = member.keys.public_key();
10358
10359 set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
10360 let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
10361
10362 let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
10364 assert!(err.contains("re-founded"), "unban: {err}");
10365 let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
10367 assert!(err.contains("re-founded"), "invite: {err}");
10368 let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
10370 assert!(err.contains("re-founded"), "kick: {err}");
10371
10372 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10374 set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
10375 let view = fetch_authority(&bed.relay, &fresh).await;
10376 assert!(view.banned.is_empty(), "the retried unban actually unbans");
10377 }
10378
10379 #[tokio::test]
10380 async fn an_uncited_kick_from_an_admin_is_not_honored() {
10381 let (bed, owner, member) = TestBed::new();
10386 bed.swap_to(&owner);
10387 let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
10388 let admin = Keys::generate();
10389 let member_pk = member.keys.public_key();
10390 grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
10391
10392 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10393 let view = fetch_authority(&bed.relay, &community).await;
10394 crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
10395
10396 let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
10397 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
10398 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
10399 let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
10400
10401 let joined = guestbook::GuestbookEvent {
10402 rumor_id: [1u8; 32],
10403 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
10404 };
10405 let kick = |citation, id: u8, at| guestbook::GuestbookEvent {
10406 rumor_id: [id; 32],
10407 entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
10408 };
10409 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
10410 let empty_bans = std::collections::BTreeSet::new();
10411 let empty_marks = std::collections::BTreeMap::new();
10412 let fold = |evs: &[guestbook::GuestbookEvent]| {
10413 fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
10414 };
10415
10416 assert!(
10417 fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
10418 "an uncited kick from an admin is not honored"
10419 );
10420 assert!(
10421 !fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
10422 "the same kick WITH its synced citation removes them"
10423 );
10424 }
10425
10426 #[tokio::test]
10427 async fn kicking_an_admin_strips_their_roles_first() {
10428 let (bed, owner, member) = TestBed::new();
10432 bed.swap_to(&owner);
10433 let community = create_community(&bed.relay, "Compose", bed.relays.clone(), None).await.unwrap();
10434 let member_pk = member.keys.public_key();
10435 let member_hex = member_pk.to_hex();
10436 let owner_hex = owner.keys.public_key().to_hex();
10437
10438 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
10439 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
10440
10441 kick_member(&bed.relay, &community, &member_pk).await.unwrap();
10442
10443 let view = fetch_authority(&bed.relay, &community).await;
10444 assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
10445 assert!(
10446 !view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
10447 "a kicked admin holds no bit"
10448 );
10449 assert!(
10450 !memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
10451 "and the directive still removed them"
10452 );
10453 }
10454
10455 #[tokio::test]
10456 async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
10457 let (bed, owner, member) = TestBed::new();
10458 bed.swap_to(&owner);
10459 let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
10460 let member_pk = member.keys.public_key();
10461 let member_hex = member_pk.to_hex();
10462 let owner_hex = owner.keys.public_key().to_hex();
10463
10464 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
10465 let view = fetch_authority(&bed.relay, &community).await;
10466 assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
10467 assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
10468
10469 let second = Keys::generate().public_key();
10472 grant_admin(&bed.relay, &community, &second).await.unwrap();
10473 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
10474 let view = fetch_authority(&bed.relay, &community).await;
10475 assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
10476 assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
10477 let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
10478 assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
10479
10480 revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
10482 let view = fetch_authority(&bed.relay, &community).await;
10483 assert!(!view.roles.is_admin(&member_hex), "revoked");
10484 assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
10485 assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
10486 }
10487
10488 #[tokio::test]
10489 async fn follow_control_persists_the_roster_for_sync_local_reads() {
10490 let (bed, owner, member) = TestBed::new();
10491 bed.swap_to(&owner);
10492 let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
10493 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10494 let member_hex = member.keys.public_key().to_hex();
10495 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
10496
10497 let session = crate::state::SessionGuard::capture();
10499 follow_control(&bed.relay, &community, &session).await.unwrap();
10500 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10501 assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
10502
10503 let withholding = MemoryRelay::new();
10506 let _ = follow_control(&withholding, &community, &session).await;
10507 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10508 assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
10509
10510 revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
10512 follow_control(&bed.relay, &community, &session).await.unwrap();
10513 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10514 assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
10515 }
10516
10517 #[tokio::test]
10518 async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
10519 let (bed, owner, member) = TestBed::new();
10520 bed.swap_to(&owner);
10521 let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
10522
10523 bed.swap_to(&member);
10524 let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
10525 assert!(err.contains("owner"), "refused before any publish: {err}");
10526
10527 bed.swap_to(&owner);
10530 let view = fetch_authority(&bed.relay, &community).await;
10531 assert!(view.roles.roles.is_empty(), "no role edition landed");
10532 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
10533 let view = fetch_authority(&bed.relay, &community).await;
10534 assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
10535 }
10536
10537 #[tokio::test]
10538 async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
10539 let (bed, owner, member) = TestBed::new();
10540 bed.swap_to(&owner);
10541 let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
10542 let member_pk = member.keys.public_key();
10543
10544 let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
10547 set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
10548 grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
10549
10550 let withholding = MemoryRelay::new();
10553 let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
10554 assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
10555
10556 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
10558 let view = fetch_authority(&bed.relay, &community).await;
10559 let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
10560 assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
10561 assert!(grant.role_ids.contains(&mod_rid));
10562 }
10563
10564 #[tokio::test]
10565 async fn fetch_authority_reflects_a_granted_admin() {
10566 let (bed, owner, member) = TestBed::new();
10567 bed.swap_to(&owner);
10568 let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
10569 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
10570 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
10571 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
10572
10573 let view = fetch_authority(&bed.relay, &community).await;
10574 let member_hex = member.keys.public_key().to_hex();
10575 assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
10576 assert!(
10577 view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
10578 "an ADMIN_ALL grant carries KICK"
10579 );
10580 assert!(view.banned.is_empty());
10581 }
10582}