1use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp};
15
16use super::super::transport::{Query, Transport};
17use super::super::{version, ChannelId, Epoch};
18use super::chat::{self, ChatEvent};
19use super::community::{ChannelV2, CommunityV2};
20use super::control;
21use super::derive::{base_rekey_group_key, channel_group_key, channel_rekey_group_key, control_group_key, GroupKey};
22use super::invite::{self, CommunityInvite};
23use super::rekey::{self, Continuity, RekeyScope};
24use super::{guestbook, stream, vsk};
25use crate::community::edition::ParsedEdition;
26use crate::state::SessionGuard;
27
28fn me_pk() -> Result<PublicKey, String> {
39 crate::state::my_public_key().ok_or_else(|| "no active identity".to_string())
40}
41
42fn now_ms() -> u64 {
43 std::time::SystemTime::now()
44 .duration_since(std::time::UNIX_EPOCH)
45 .map(|d| d.as_millis() as u64)
46 .unwrap_or(0)
47}
48
49pub async fn create_community<T: Transport + ?Sized>(
54 transport: &T,
55 name: &str,
56 relays: Vec<String>,
57 description: Option<String>,
58) -> Result<CommunityV2, String> {
59 let session = SessionGuard::capture();
60 let signer = crate::signer::active_signer()?;
61 let owner_pk = me_pk()?;
62 let at_ms = now_ms();
63
64 let meta = control::CommunityMetadata {
65 name: name.to_string(),
66 description: description.clone(),
67 relays: relays.clone(),
68 ..Default::default()
69 };
70 let genesis = control::genesis_signed(owner_pk, &signer, meta, at_ms / 1000).await.map_err(|e| e.to_string())?;
71 let community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
72
73 if !session.is_valid() {
78 return Err("account changed during community creation".to_string());
79 }
80 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
86 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
87 for wrap in &genesis.wraps {
88 if let Ok((ed, _)) = control::open_control_edition(wrap, &control) {
89 let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
90 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
91 }
92 }
93 crate::db::community::save_community_v2(&community)?;
94 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
97
98 for wrap in &genesis.wraps {
103 transport.publish_durable(wrap, &community.relays).await?;
104 }
105
106 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
110 let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
111 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
112 let _ = transport.publish_durable(&join_wrap, &community.relays).await;
113 }
114
115 match republish_community_list(transport, Some(community.id())).await {
117 Ok(true) => {}
118 Ok(false) => republish_community_list_durable(Some(*community.id())),
119 Err(e) => {
120 crate::log_warn!("[CommunityList] failed to record this community across devices ({}) — retrying", e);
121 republish_community_list_durable(Some(*community.id()));
122 }
123 }
124 Ok(community)
125}
126
127pub async fn create_migration_twin<T: Transport + ?Sized>(
132 transport: &T,
133 name: &str,
134 relays: Vec<String>,
135 description: Option<String>,
136 primary: (ChannelId, String),
137) -> Result<CommunityV2, String> {
138 let session = SessionGuard::capture();
139 let signer = crate::signer::active_signer()?;
140 let owner_pk = me_pk()?;
141 let at_ms = now_ms();
142
143 let meta = control::CommunityMetadata {
144 name: name.to_string(),
145 description: description.clone(),
146 relays: relays.clone(),
147 ..Default::default()
148 };
149 let primary_name = primary.1.clone();
150 let genesis = control::genesis_signed_with_primary(owner_pk, &signer, meta, at_ms / 1000, Some(primary))
151 .await
152 .map_err(|e| e.to_string())?;
153 let mut community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
154 if let Some(ch) = community.channels.first_mut() {
157 ch.name = primary_name;
158 }
159 if !session.is_valid() {
160 return Err("account changed during twin creation".to_string());
161 }
162 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
163 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
164 for wrap in &genesis.wraps {
165 if let Ok((ed, _)) = control::open_control_edition(wrap, &control) {
166 let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
167 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
168 }
169 }
170 crate::db::community::save_community_v2(&community)?;
171 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
172 for wrap in &genesis.wraps {
173 transport.publish_durable(wrap, &community.relays).await?;
174 }
175 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
177 let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
178 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
179 let _ = transport.publish_durable(&join_wrap, &community.relays).await;
180 }
181 Ok(community)
182}
183
184pub async fn clone_banlist_to_twin<T: Transport + ?Sized>(
188 transport: &T,
189 twin: &CommunityV2,
190 banned: &[String],
191) -> Result<(), String> {
192 if banned.is_empty() {
193 return Ok(());
194 }
195 set_banlist(transport, twin, banned).await
196}
197
198pub async fn clone_governance_to_twin<T: Transport + ?Sized>(
209 transport: &T,
210 twin: &CommunityV2,
211 v1_roles: &crate::community::roles::CommunityRoles,
212 banned: &[String],
213) -> Result<(), String> {
214 use crate::community::roles::Permissions;
215 let owner = twin.owner()?;
216 for grant in &v1_roles.grants {
217 if !v1_roles.effective_permissions(&grant.member).contains(Permissions::ADMIN_ALL) {
218 continue; }
220 if banned.contains(&grant.member) {
221 continue; }
223 let Ok(member) = PublicKey::parse(&grant.member) else { continue };
224 if member == owner {
225 continue; }
227 grant_admin(transport, twin, &member).await?;
228 }
229 Ok(())
230}
231
232pub fn twin_join_material(twin: &CommunityV2) -> super::list::JoinMaterial {
234 join_material(twin)
235}
236
237pub async fn send_message<T: Transport + ?Sized>(
241 transport: &T,
242 community: &CommunityV2,
243 channel_id: &ChannelId,
244 content: &str,
245) -> Result<String, String> {
246 send_chat_message(transport, community, channel_id, content, None, &[], vec![]).await
247}
248
249pub async fn send_chat_message<T: Transport + ?Sized>(
253 transport: &T,
254 community: &CommunityV2,
255 channel_id: &ChannelId,
256 content: &str,
257 reply_to: Option<(&str, &str)>,
258 emoji: &[(&str, &str)],
259 extra_tags: Vec<nostr_sdk::prelude::Tag>,
260) -> Result<String, String> {
261 send_chat_message_at(transport, community, channel_id, content, reply_to, emoji, extra_tags, now_ms()).await
262}
263
264#[allow(clippy::too_many_arguments)]
269pub async fn send_chat_message_at<T: Transport + ?Sized>(
270 transport: &T,
271 community: &CommunityV2,
272 channel_id: &ChannelId,
273 content: &str,
274 reply_to: Option<(&str, &str)>,
275 emoji: &[(&str, &str)],
276 extra_tags: Vec<nostr_sdk::prelude::Tag>,
277 at_ms: u64,
278) -> Result<String, String> {
279 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
280 let rumor = chat::build_message_rumor(author_pk, channel_id, epoch, content, reply_to, emoji, extra_tags, at_ms);
281 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
282}
283
284#[allow(clippy::too_many_arguments)]
289pub async fn send_reaction<T: Transport + ?Sized>(
290 transport: &T,
291 community: &CommunityV2,
292 channel_id: &ChannelId,
293 target_id_hex: &str,
294 target_author_hex: &str,
295 target_kind: u16,
296 emoji_content: &str,
297 emoji: Option<(&str, &str)>,
298) -> Result<String, String> {
299 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
300 let at_ms = now_ms();
301 let rumor =
302 chat::build_reaction_rumor(author_pk, channel_id, epoch, target_id_hex, target_author_hex, target_kind, emoji_content, emoji, at_ms);
303 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
304}
305
306pub async fn send_edit<T: Transport + ?Sized>(
310 transport: &T,
311 community: &CommunityV2,
312 channel_id: &ChannelId,
313 target_id_hex: &str,
314 new_content: &str,
315) -> Result<String, String> {
316 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
317 let at_ms = now_ms();
318 let rumor = chat::build_edit_rumor(author_pk, channel_id, epoch, target_id_hex, new_content, at_ms);
319 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
320}
321
322pub async fn send_delete<T: Transport + ?Sized>(
326 transport: &T,
327 community: &CommunityV2,
328 channel_id: &ChannelId,
329 target_id_hex: &str,
330 target_kind: u16,
331) -> Result<String, String> {
332 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
333 let at_ms = now_ms();
334 let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, None);
335 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
336}
337
338pub async fn moderation_delete<T: Transport + ?Sized>(
348 transport: &T,
349 community: &CommunityV2,
350 channel_id: &ChannelId,
351 target_id_hex: &str,
352 target_kind: u16,
353 target_author: &PublicKey,
354) -> Result<String, String> {
355 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
356 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
357 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
358 return Err("this community is dissolved — it accepts no new moderation actions".to_string());
359 }
360 let owner_hex = community.owner()?.to_hex();
361 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
362 if !crate::community::moderation::can_hide(
363 Some(&owner_hex),
364 &roster,
365 &author_pk.to_hex(),
366 &target_author.to_hex(),
367 ) {
368 return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
369 }
370 let at_ms = now_ms();
371 let citation = required_authority_citation(community, &author_pk)?;
372 let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, citation.as_ref());
373 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
374}
375
376pub async fn send_webxdc_signal<T: Transport + ?Sized>(
383 transport: &T,
384 community: &CommunityV2,
385 channel_id: &ChannelId,
386 topic_id: &str,
387 node_addr: Option<&str>,
388) -> Result<(), String> {
389 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
390 let at_ms = now_ms();
391 let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
392 let rumor = chat::build_webxdc_rumor(author_pk, channel_id, epoch, &content, vec![], at_ms);
393 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await.map(|_| ())
394}
395
396pub async fn send_typing<T: Transport + ?Sized>(
398 transport: &T,
399 community: &CommunityV2,
400 channel_id: &ChannelId,
401) -> Result<(), String> {
402 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
403 let at_ms = now_ms();
404 let rumor = chat::build_typing_rumor(author_pk, channel_id, epoch, at_ms);
405 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
406}
407
408fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(PublicKey, GroupKey, Epoch, SessionGuard), String> {
414 let session = SessionGuard::capture();
415 let author_pk = me_pk()?;
416 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
417 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
418 return Err("this community has been dissolved".to_string());
419 }
420 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author_pk.to_hex()) {
424 return Err("you are banned from this community".to_string());
425 }
426 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
427 if ch.private && ch.key.is_none() {
428 return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
429 }
430 let (secret, epoch) = community.channel_secret(ch);
431 Ok((author_pk, channel_group_key(&secret, channel_id, epoch), epoch, session))
432}
433
434#[allow(clippy::too_many_arguments)]
437async fn publish_chat<T: Transport + ?Sized>(
438 transport: &T,
439 community: &CommunityV2,
440 session: &SessionGuard,
441 group: &GroupKey,
442 author_pk: PublicKey,
443 channel_id: &ChannelId,
444 epoch: Epoch,
445 rumor: nostr_sdk::prelude::UnsignedEvent,
446 at_ms: u64,
447 ephemeral: bool,
448) -> Result<String, String> {
449 let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
450 let signer = crate::signer::active_signer()?;
451 let (wrap, _p_tag_keys) = chat::seal_chat_rumor_signed(&signer, author_pk, &rumor, group, Timestamp::from_secs(at_ms / 1000), ephemeral).await
452 .map_err(|e| e.to_string())?;
453 if !session.is_valid() {
454 return Err("account changed before send".to_string());
455 }
456 transport.publish(&wrap, &community.relays).await?;
457 if !ephemeral {
463 if !session.is_valid() {
464 return Ok(rumor_id);
465 }
466 crate::db::community::store_message_key(&rumor_id, &wrap.id.to_hex(), group.keys(), &community.relays)?;
467 }
468 if !ephemeral {
473 if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
474 let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
475 let outcome = {
476 let mut st = crate::state::STATE.lock().await;
477 if !session.is_valid() {
478 return Ok(rumor_id); }
480 super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author_pk)
481 };
482 if let Some(outcome) = outcome {
483 if !session.is_valid() {
484 return Ok(rumor_id);
485 }
486 super::inbound::persist_chat(&channel_hex, &outcome).await;
487 }
488 }
489 }
490 Ok(rumor_id)
491}
492
493pub struct FetchedEvent {
496 pub event: ChatEvent,
497 pub epoch: Epoch,
498}
499
500fn heal_own_wrap_key(event: &ChatEvent, group: &GroupKey, relays: &[String]) {
507 if !matches!(event, ChatEvent::Message { .. } | ChatEvent::Reaction { .. }) {
508 return;
509 }
510 let opened = event.opened();
511 if crate::state::my_public_key() != Some(opened.author) {
512 return;
513 }
514 let rumor_hex = opened.rumor_id.to_hex();
515 if !matches!(crate::db::community::get_message_key(&rumor_hex), Ok(None)) {
518 return;
519 }
520 if crate::db::community::store_message_key(&rumor_hex, &opened.wrapper_id.to_hex(), group.keys(), relays).is_ok() {
521 crate::traits::emit_event("message_delete_meta_changed", &serde_json::json!({ "id": rumor_hex }));
524 }
525}
526
527pub async fn fetch_channel<T: Transport + ?Sized>(
531 transport: &T,
532 community: &CommunityV2,
533 channel_id: &ChannelId,
534 limit: usize,
535) -> Result<Vec<FetchedEvent>, String> {
536 fetch_channel_history(transport, community, channel_id, limit, 1, None, crate::community::transport::Evidence::Quorum, |_| true).await
537}
538
539pub async fn fetch_channel_history<T: Transport + ?Sized>(
553 transport: &T,
554 community: &CommunityV2,
555 channel_id: &ChannelId,
556 page: usize,
557 max_pages: usize,
558 since: Option<u64>,
559 evidence: crate::community::transport::Evidence,
560 mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
561) -> Result<Vec<FetchedEvent>, String> {
562 let session = SessionGuard::capture();
565 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
566 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
571 let coords: Vec<([u8; 32], Epoch)> = if ch.private {
572 let Some(current) = ch.key else {
573 return Ok(Vec::new());
574 };
575 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
576 let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
577 if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
578 held.push((ch.epoch, current));
579 }
580 held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
583 } else {
584 let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
585 if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
586 roots.push((community.root_epoch, community.community_root));
587 }
588 roots.into_iter().map(|(ep, root)| (root, ep)).collect()
589 };
590 if coords.is_empty() {
591 return Ok(Vec::new());
592 }
593
594 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
595 let mut seen_rumors = std::collections::HashSet::new();
596 let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
597 let mut until: Option<u64> = None;
598 let mut oldest: Option<u64> = None;
599 for _ in 0..max_pages {
600 let mut wraps: Vec<Event> = Vec::new();
606 let mut wrap_ids: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
607 for (secret, epoch) in &coords {
608 let plane = channel_group_key(secret, channel_id, *epoch);
609 let q = Query {
610 kinds: vec![stream::KIND_WRAP],
611 authors: vec![plane.pk_hex()],
612 since,
613 until,
614 limit: Some(page),
615 evidence,
616 ..Default::default()
617 };
618 if let Ok(evs) = transport.fetch_plane(plane.keys(), &q, &community.relays).await {
619 for e in evs {
620 if wrap_ids.insert(e.id) {
621 wraps.push(e);
622 }
623 }
624 }
625 }
626 if wraps.is_empty() {
627 break;
628 }
629 let mut fresh = 0usize;
630 let mut page_events: Vec<FetchedEvent> = Vec::new();
631 for wrap in &wraps {
632 if !seen_wraps.insert(wrap.id) {
633 continue;
634 }
635 fresh += 1;
636 let at = wrap.created_at.as_secs();
637 if oldest.is_none_or(|o| at < o) {
638 oldest = Some(at);
639 }
640 for (secret, epoch) in &coords {
642 let group = channel_group_key(secret, channel_id, *epoch);
643 if wrap.pubkey != group.pk() {
644 continue;
645 }
646 if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
647 let id = event.opened().rumor_id;
648 if seen_rumors.insert(id) {
649 if session.is_valid() {
650 heal_own_wrap_key(&event, &group, &community.relays);
651 }
652 page_events.push(FetchedEvent { event, epoch: *epoch });
653 }
654 }
655 break;
656 }
657 }
658 if fresh == 0 {
659 if wraps.len() < page {
660 break; }
662 let Some(o) = oldest else { break };
666 if o == 0 {
667 break;
668 }
669 crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
670 until = Some(o - 1);
671 continue;
672 }
673 let stop = !page_events.is_empty() && !keep_paging(&page_events);
674 out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
675 if stop {
676 break; }
678 until = oldest; }
680 out.sort_by_key(|(ms, _)| *ms);
681 Ok(out.into_iter().map(|(_, e)| e).collect())
682}
683
684#[derive(Debug, Clone, Copy, PartialEq, Eq)]
693pub enum BundleAudience {
694 Link,
696 Member(PublicKey),
698}
699
700pub fn bundle_of(
706 community: &CommunityV2,
707 audience: BundleAudience,
708 creator: Option<PublicKey>,
709 expires_at_ms: Option<u64>,
710 label: Option<String>,
711) -> CommunityInvite {
712 bundle_of_with_overlay(community, audience, creator, expires_at_ms, label, &[], &[])
713}
714
715pub fn bundle_of_with_overlay(
719 community: &CommunityV2,
720 audience: BundleAudience,
721 creator: Option<PublicKey>,
722 expires_at_ms: Option<u64>,
723 label: Option<String>,
724 with: &[String],
725 without: &[String],
726) -> CommunityInvite {
727 let hex = crate::simd::hex::bytes_to_hex_32;
728 let cid_hex = hex(&community.identity.community_id.0);
729 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
730 let owner_hex = community.owner().ok().map(|o| o.to_hex());
731 let recipient_hex = match audience {
732 BundleAudience::Link => None,
733 BundleAudience::Member(pk) => Some(pk.to_hex()),
734 };
735 let channels = community
736 .vendable_channels(&roster, owner_hex.as_deref(), recipient_hex.as_deref(), with, without)
737 .into_iter()
738 .map(|c| invite::ChannelGrant {
739 id: hex(&c.id.0),
740 key: hex(&c.key.unwrap_or(community.community_root)),
741 epoch: c.epoch.0,
742 name: c.name.clone(),
743 })
744 .collect();
745 CommunityInvite {
746 community_id: hex(&community.identity.community_id.0),
747 owner: hex(&community.identity.owner_xonly),
748 owner_salt: hex(&community.identity.owner_salt),
749 community_root: hex(&community.community_root),
750 root_epoch: community.root_epoch.0,
751 channels,
752 relays: community.relays.clone(),
753 name: community.name.clone(),
754 icon: community.icon.clone(),
757 expires_at: expires_at_ms,
758 creator_npub: creator.map(|p| p.to_hex()),
759 label,
760 extra: Default::default(),
761 }
762}
763
764pub async fn send_direct_invite<T: Transport + ?Sized>(
770 transport: &T,
771 community: &CommunityV2,
772 recipient: &PublicKey,
773 expires_at_ms: Option<u64>,
774 label: Option<String>,
775) -> Result<Event, String> {
776 let session = SessionGuard::capture();
777 assert_current_root(community)?;
780 let signer = crate::signer::active_signer()?;
781 let inviter_pk = me_pk()?;
782 let bundle = bundle_of(community, BundleAudience::Member(*recipient), Some(inviter_pk), expires_at_ms, label);
783 let wrap = invite::build_direct_invite_signed(&signer, inviter_pk, recipient, &bundle).await.map_err(|e| e.to_string())?;
784 if !session.is_valid() {
785 return Err("account changed before sending invite".to_string());
786 }
787 transport.publish(&wrap, &community.relays).await?;
788 Ok(wrap)
789}
790
791pub struct MintedLink {
795 pub url: String,
796 pub bundle_event: Event,
797 pub link_signer: Keys,
798 pub token: [u8; super::derive::TOKEN_LEN],
799 pub expires_at_ms: Option<u64>,
804 pub label: Option<String>,
805}
806
807pub async fn mint_public_link<T: Transport + ?Sized>(
813 transport: &T,
814 community: &CommunityV2,
815 base: &str,
816 expires_at_ms: Option<u64>,
817 label: Option<String>,
818) -> Result<MintedLink, String> {
819 let session = SessionGuard::capture();
820 let mut token = [0u8; super::derive::TOKEN_LEN];
821 token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
822 let link_signer = Keys::generate();
823 let bundle = bundle_of(community, BundleAudience::Link, Some(me_pk()?), expires_at_ms, label.clone());
824 let bundle_key = super::derive::invite_bundle_key(&token);
825 let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
826 let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
827
828 if !session.is_valid() {
829 return Err("account changed before minting link".to_string());
830 }
831 transport.publish_durable(&bundle_event, &community.relays).await?;
832 let minted = MintedLink { url, bundle_event, link_signer, token, expires_at_ms, label: label.clone() };
833 let _ = record_minted_link(transport, community, &minted).await;
837 if session.is_valid() {
842 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
843 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
844 let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
845 }
846 Ok(minted)
847}
848
849async fn fetch_invite_list<T: Transport + ?Sized>(
858 transport: &T,
859 relays: &[String],
860) -> Result<Option<invite::InviteList>, String> {
861 let signer = crate::signer::active_signer()?;
862 let my_pk = me_pk()?;
863 let query = Query {
864 kinds: vec![super::kind::INVITE_LIST],
865 authors: vec![my_pk.to_hex()],
866 limit: Some(4),
867 evidence: crate::community::transport::Evidence::Full,
868 ..Default::default()
869 };
870 let events = transport.fetch(&query, relays).await?;
871 let mut best: Option<(u64, invite::InviteList)> = None;
872 for e in events {
873 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
874 let at = e.created_at.as_secs();
875 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
876 best = Some((at, l));
877 }
878 }
879 }
880 Ok(best.map(|(_, l)| l))
881}
882
883fn live_signers_for(list: &invite::InviteList, community_id_hex: &str, now_ms: u64) -> Vec<PublicKey> {
891 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
892 list.entries
893 .iter()
894 .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
895 .filter(|e| !e.expires_at.is_some_and(|exp| now_ms > exp))
896 .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
897 .collect()
898}
899
900async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
904 let my_pk = me_pk()?;
905 let eid = super::derive::invite_links_locator(community.id(), &my_pk.to_bytes());
906 let content = invite::build_registry_content(live_signers);
907 publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content).await?;
908 refresh_invite_registry_cache(transport, community, session).await;
912 Ok(())
913}
914
915async fn refresh_invite_registry_cache<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard) {
919 let Ok(owner) = community.owner() else { return };
920 let Some(editions) = fetch_control_plane_whole(transport, community).await else { return };
921 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
922 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
923 .unwrap_or_default()
924 .into_iter()
925 .filter(|(_, f)| f.0 == community.root_epoch.0)
926 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
927 .collect();
928 let authority = fold_authority(community, &editions, &floors);
929 let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
930 if session.is_valid() {
931 let _ = crate::db::community::set_community_invite_registry(&cid_hex, &flatten_link_sets(&sets));
932 let _ = crate::db::community::replace_invite_link_sets(&cid_hex, &sets);
933 }
934}
935
936async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
939 let session = SessionGuard::capture();
940 let signer = crate::signer::active_signer()?;
941 let my_pk = me_pk()?;
942 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
943 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
944 let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
949 if !list.entries.iter().any(|e| e.token == token_hex) {
950 list.entries.push(invite::InviteEntry {
951 token: token_hex,
952 signer_sk: minted.link_signer.secret_key().to_secret_hex(),
953 community_id: cid_hex.clone(),
954 url: minted.url.clone(),
955 label: minted.label.clone(),
956 created_at: now_ms() / 1000,
957 expires_at: minted.expires_at_ms,
958 extra: Default::default(),
959 });
960 }
961 if !session.is_valid() {
962 return Err("account changed during link record".to_string());
963 }
964 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
965 transport.publish(&event, &community.relays).await?;
966 let signers = live_signers_for(&list, &cid_hex, now_ms());
967 publish_invite_registry(transport, community, &session, &signers).await
968}
969
970pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
976 let session = SessionGuard::capture();
977 let signer = crate::signer::active_signer()?;
978 let my_pk = me_pk()?;
979 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
980 let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
981 let entry = list
982 .entries
983 .iter()
984 .find(|e| e.token == token_hex && e.community_id == cid_hex)
985 .cloned()
986 .ok_or("no such link in the invite list")?;
987 let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
989 let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
990 if !session.is_valid() {
991 return Err("account changed during revoke".to_string());
992 }
993 transport.publish_durable(&revocation, &community.relays).await?;
994 list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
996 list.entries.retain(|e| e.token != token_hex);
997 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
998 transport.publish(&event, &community.relays).await?;
999 let signers = live_signers_for(&list, &cid_hex, now_ms());
1000 publish_invite_registry(transport, community, &session, &signers).await?;
1001 if session.is_valid() {
1003 let _ = crate::db::community::delete_public_invite(token_hex);
1004 }
1005 Ok(())
1006}
1007
1008pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1013 let session = SessionGuard::capture();
1014 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1015 let signer = crate::signer::active_signer()?;
1020 let my_pk = me_pk()?;
1021 let query = Query {
1022 kinds: vec![super::kind::INVITE_LIST],
1023 authors: vec![my_pk.to_hex()],
1024 limit: Some(4),
1025 ..Default::default()
1026 };
1027 let events = transport.fetch(&query, &community.relays).await?;
1028 let mut best: Option<(u64, invite::InviteList)> = None;
1029 for e in events {
1030 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
1031 let at = e.created_at.as_secs();
1032 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
1033 best = Some((at, l));
1034 }
1035 }
1036 }
1037 let Some((_, list)) = best else {
1038 return Ok(());
1039 };
1040 let creator = my_pk;
1041 let now = now_ms();
1042 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1043 for entry in &list.entries {
1044 if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
1045 continue;
1046 }
1047 if entry.expires_at.is_some_and(|exp| now > exp) {
1050 continue;
1051 }
1052 let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
1053 let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
1054 let bundle = bundle_of(community, BundleAudience::Link, Some(creator), entry.expires_at, entry.label.clone());
1055 let bundle_key = super::derive::invite_bundle_key(&token);
1056 if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
1057 if !session.is_valid() {
1058 return Err("account changed during link refresh".to_string());
1059 }
1060 let _ = transport.publish_durable(&event, &community.relays).await;
1061 }
1062 }
1063 let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
1073 if !mine_here {
1074 return Ok(());
1075 }
1076 let signers = live_signers_for(&list, &cid_hex, now);
1077 if !session.is_valid() {
1078 return Err("account changed during link refresh".to_string());
1079 }
1080 let _ = publish_invite_registry(transport, community, &session, &signers).await;
1081 Ok(())
1082}
1083
1084pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1089 let Ok(owner) = community.owner() else { return false };
1090 let Some(editions) = fetch_control_plane_whole(transport, community).await else { return true };
1094 let cid = community.id();
1095 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1096 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1097 .unwrap_or_default()
1098 .into_iter()
1099 .filter(|(_, f)| f.0 == community.root_epoch.0)
1100 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1101 .collect();
1102 let authority = fold_authority(community, &editions, &floors);
1103 !live_invite_link_sets(cid, &owner.to_hex(), &editions, &authority, &floors).is_empty()
1104}
1105
1106async fn fetch_control_plane_whole<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Option<Vec<ParsedEdition>> {
1112 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1113 let mut editions: Vec<ParsedEdition> = Vec::new();
1114 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1115 let mut oldest: Option<u64> = None;
1116 let mut until: Option<u64> = None;
1117 for page in 0..COMPACT_MAX_PAGES {
1118 let query = Query {
1122 kinds: vec![stream::KIND_WRAP],
1123 authors: vec![control.pk_hex()],
1124 until,
1125 limit: Some(FOLLOW_PAGE),
1126 evidence: crate::community::transport::Evidence::Quorum,
1127 ..Default::default()
1128 };
1129 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return None };
1130 let mut fresh = 0usize;
1131 for w in &wraps {
1132 if !seen_wraps.insert(w.id) {
1133 continue;
1134 }
1135 fresh += 1;
1136 let at = w.created_at.as_secs();
1137 if oldest.is_none_or(|o| at < o) {
1138 oldest = Some(at);
1139 }
1140 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1141 editions.push(ed);
1142 }
1143 }
1144 if fresh == 0 {
1145 if wraps.len() >= FOLLOW_PAGE {
1146 return None; }
1148 return Some(editions);
1149 }
1150 until = oldest;
1151 if page + 1 == COMPACT_MAX_PAGES {
1152 return None;
1153 }
1154 }
1155 Some(editions)
1156}
1157
1158fn live_invite_link_sets(
1163 cid: &crate::community::CommunityId,
1164 owner_hex: &str,
1165 editions: &[ParsedEdition],
1166 authority: &AuthoritySet,
1167 floors: &Floors,
1168) -> Vec<crate::db::community::InviteLinkSetRow> {
1169 use crate::community::roles::Permissions;
1170 use std::collections::BTreeMap;
1171 let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
1172 for e in editions {
1173 if e.vsk == vsk::INVITE_LINKS {
1174 by_eid.entry(e.entity_id).or_default().push(e);
1175 }
1176 }
1177 let mut sets: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1178 for (eid, group) in &by_eid {
1179 let authed: Vec<&ParsedEdition> = group
1185 .iter()
1186 .copied()
1187 .filter(|p| {
1188 let author = p.author.to_hex();
1189 !authority.banned.contains(&author)
1191 && authority.roles.is_authorized(&author, Some(owner_hex), Permissions::CREATE_INVITE)
1192 && super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
1193 })
1194 .collect();
1195 if authed.is_empty() {
1196 continue;
1197 }
1198 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
1199 let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
1200 if let Ok(signers) = invite::parse_registry_content(&authed[hi].content) {
1201 if signers.is_empty() {
1202 continue; }
1204 sets.push(crate::db::community::InviteLinkSetRow {
1205 creator_hex: authed[hi].author.to_hex(),
1206 locators: signers.iter().map(|p| p.to_hex()).collect(),
1207 });
1208 }
1209 }
1210 sets
1211}
1212
1213fn flatten_link_sets(sets: &[crate::db::community::InviteLinkSetRow]) -> Vec<String> {
1215 let mut flat: Vec<String> = sets.iter().flat_map(|s| s.locators.iter().cloned()).collect();
1216 flat.sort();
1217 flat.dedup();
1218 flat
1219}
1220
1221async fn accept_bundle<T: Transport + ?Sized>(
1227 transport: &T,
1228 session: &SessionGuard,
1229 bundle: &CommunityInvite,
1230 invited_by: Option<PublicKey>,
1231 announce_join: bool,
1232) -> Result<CommunityV2, String> {
1233 let signer = crate::signer::active_signer()?;
1234 let my_pk = me_pk()?;
1235 let at_ms = now_ms();
1236 if bundle.expired(at_ms) {
1238 return Err("this invite has expired".to_string());
1239 }
1240 let community = CommunityV2::from_bundle(bundle, at_ms)?;
1242 let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
1245
1246 let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
1255 v.session.is_valid()
1256 && v.at.elapsed() < VERIFIED_PREVIEW_TTL
1257 && v.community_id == community.id().0
1258 && v.community_root == community.community_root
1259 });
1260 let (community, join_heads, join_banlist) = match handoff {
1261 Some(v) => {
1262 let mut c = v.folded;
1263 c.created_at_ms = at_ms;
1265 (c, v.heads, v.banned)
1266 }
1267 None => verify_owner_root_and_reconcile(transport, community).await?,
1268 };
1269
1270 if is_dissolved(transport, &community).await {
1272 return Err("this community has been dissolved".to_string());
1273 }
1274
1275 if join_banlist.contains(&my_pk.to_hex()) {
1280 return Err("you are banned from this community".to_string());
1281 }
1282
1283 if !session.is_valid() {
1287 return Err("account changed during join".to_string());
1288 }
1289 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1294 for h in &join_heads {
1295 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)?;
1296 }
1297 crate::db::community::save_community_v2(&community)?;
1298 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
1301 for ch in &community.channels {
1304 if let (true, Some(key)) = (ch.private, ch.key) {
1305 let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
1306 }
1307 }
1308
1309 if announce_join && !already_held {
1315 let attribution = invited_by
1316 .map(|p| p.to_hex())
1317 .or_else(|| bundle.creator_npub.clone())
1318 .zip(Some(bundle.label.clone().unwrap_or_default()));
1319 let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
1320 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1321 let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
1322 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1323 let _ = transport.publish(&join_wrap, &community.relays).await;
1324 }
1325 }
1326
1327 match republish_community_list(transport, Some(community.id())).await {
1331 Ok(true) => {}
1332 Ok(false) => republish_community_list_durable(Some(*community.id())),
1333 Err(e) => {
1334 crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
1335 republish_community_list_durable(Some(*community.id()));
1336 }
1337 }
1338 Ok(community)
1339}
1340
1341async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
1358 transport: &T,
1359 community: CommunityV2,
1360) -> Result<(CommunityV2, Vec<FoldedHead>, std::collections::BTreeSet<String>), String> {
1361 let owner = community.owner()?;
1362 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1363 let control_pk = control.pk_hex();
1364
1365 super::streamauth::prime(&community);
1372
1373 const PAGE: usize = 500;
1394 const MAX_PAGES: usize = 4;
1395 const FAR_FUTURE_SECS: u64 = 4_102_444_800; let mut editions: Vec<ParsedEdition> = Vec::new();
1397 let mut all_editions: Vec<ParsedEdition> = Vec::new();
1398 let mut found_genesis = false;
1399 let mut compacted_metadata = false;
1407 crate::log_debug!(
1408 "[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
1409 &control_pk[..12], community.root_epoch, community.relays
1410 );
1411 let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
1412 found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
1413 };
1414 for attempt in 0..2 {
1415 editions.clear();
1416 all_editions.clear();
1417 compacted_metadata = false;
1418 let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1419 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1420 for page_no in 0..MAX_PAGES {
1421 let query = Query {
1422 kinds: vec![stream::KIND_WRAP],
1423 authors: vec![control_pk.clone()],
1424 until,
1425 limit: Some(PAGE),
1426 evidence: crate::community::transport::Evidence::Full,
1427 ..Default::default()
1428 };
1429 let wraps = transport.fetch(&query, &community.relays).await?;
1430 crate::log_trace!(
1431 "[JoinVerify] attempt {} page {}: fetched {} wraps",
1432 attempt, page_no, wraps.len()
1433 );
1434 let mut oldest = u64::MAX;
1438 let mut fresh = 0usize;
1439 for w in &wraps {
1440 if !seen_wraps.insert(w.id) {
1441 continue;
1442 }
1443 fresh += 1;
1444 oldest = oldest.min(w.created_at.as_secs());
1445 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1446 crate::log_trace!(
1447 "[JoinVerify] edition vsk={} eid={} owner={} at={}",
1448 ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
1449 ed.author == owner, w.created_at.as_secs()
1450 );
1451 if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1452 if ed.author == owner {
1453 found_genesis = true;
1454 } else {
1455 compacted_metadata = true;
1456 }
1457 }
1458 if ed.author == owner {
1459 editions.push(ed.clone());
1460 }
1461 all_editions.push(ed);
1464 }
1465 }
1466 crate::log_debug!(
1467 "[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
1468 attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
1469 );
1470 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
1471 break; }
1473 until = Some(oldest);
1474 }
1475 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1476 break;
1477 }
1478 if attempt == 0 {
1479 if let Some(client) = crate::state::nostr_client() {
1486 super::streamauth::prime_auth(&client, &community.relays).await;
1487 }
1488 }
1489 }
1490 if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1491 return Err(
1492 "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"
1493 .to_string(),
1494 );
1495 }
1496 let empty_floors = Floors::new();
1505 let authority = AuthoritySet::owner_only();
1506 let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1507 let join_banlist = fold_authority(&community, &all_editions, &empty_floors).banned;
1512 Ok((fold.updated.unwrap_or(community), fold.heads, join_banlist))
1513}
1514
1515pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1519 let session = SessionGuard::capture();
1520 let signer = crate::signer::active_signer()?;
1521 let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
1522 accept_bundle(transport, &session, &bundle, Some(inviter), true).await
1523}
1524
1525pub async fn accept_parked_invite<T: Transport + ?Sized>(
1531 transport: &T,
1532 bundle_json: &str,
1533 inviter_hex: Option<&str>,
1534) -> Result<CommunityV2, String> {
1535 let session = SessionGuard::capture();
1536 let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1537 let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1538 accept_bundle(transport, &session, &bundle, invited_by, true).await
1539}
1540
1541pub async fn accept_migration_material<T: Transport + ?Sized>(
1547 transport: &T,
1548 jm: &super::list::JoinMaterial,
1549) -> Result<CommunityV2, String> {
1550 let session = SessionGuard::capture();
1551 let bundle = material_to_invite(jm);
1552 accept_bundle(transport, &session, &bundle, None, true).await
1553}
1554
1555pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1562 let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1563 let query = Query {
1568 kinds: vec![super::kind::INVITE_BUNDLE],
1569 authors: vec![parsed.link_signer.to_hex()],
1570 ..Default::default()
1571 };
1572 let relays = if parsed.bootstrap_relays.is_empty() {
1573 invite::stock_relays()
1574 } else {
1575 parsed.bootstrap_relays.clone()
1576 };
1577 let events = match transport.fetch(&query, &relays).await {
1582 Ok(evs) => evs,
1583 Err(_) => {
1584 wait_for_bootstrap_relay(&relays).await;
1585 transport.fetch(&query, &relays).await?
1586 }
1587 };
1588 let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1589
1590 let mut newest_live: Option<(u64, CommunityInvite)> = None;
1592 for event in &events {
1593 match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1594 Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1595 Ok(invite::BundleState::Live(bundle)) => {
1596 let at = event.created_at.as_secs();
1597 if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1598 newest_live = Some((at, *bundle));
1599 }
1600 }
1601 Err(_) => {} }
1603 }
1604 newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1605}
1606
1607async fn wait_for_bootstrap_relay(relays: &[String]) {
1611 let Some(client) = crate::state::nostr_client() else { return };
1612 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8);
1613 loop {
1614 for url in relays {
1615 if let Ok(Some(relay)) = client.relay(url).await {
1616 if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
1617 return;
1618 }
1619 }
1620 }
1621 if tokio::time::Instant::now() >= deadline {
1622 return;
1623 }
1624 tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1625 }
1626}
1627
1628struct VerifiedPreview {
1634 session: SessionGuard,
1635 at: std::time::Instant,
1636 community_id: [u8; 32],
1637 community_root: [u8; 32],
1638 folded: CommunityV2,
1639 heads: Vec<FoldedHead>,
1640 banned: std::collections::BTreeSet<String>,
1643}
1644static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
1645const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
1646
1647pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1654 let bundle = fetch_public_bundle(transport, url).await?;
1655 preview_bundle(transport, &bundle).await
1656}
1657
1658pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
1663 let community = CommunityV2::from_bundle(bundle, 0)?;
1664 match verify_owner_root_and_reconcile(transport, community.clone()).await {
1665 Ok((folded, heads, banned)) => {
1666 *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
1667 session: SessionGuard::capture(),
1668 at: std::time::Instant::now(),
1669 community_id: folded.id().0,
1670 community_root: folded.community_root,
1671 folded: folded.clone(),
1672 heads,
1673 banned,
1674 });
1675 Ok(folded)
1676 }
1677 Err(_) => Ok(community),
1678 }
1679}
1680
1681pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1683 let session = SessionGuard::capture();
1685 let bundle = fetch_public_bundle(transport, url).await?;
1686 if !session.is_valid() {
1687 return Err("account changed during join".to_string());
1688 }
1689 accept_bundle(transport, &session, &bundle, None, true).await
1690}
1691
1692pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1694 let session = SessionGuard::capture();
1695 let signer = crate::signer::active_signer()?;
1696 let my_pk = me_pk()?;
1697 let at_ms = now_ms();
1698 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1699 let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
1700 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1701 let _ = transport.publish(&wrap, &community.relays).await;
1702 }
1703 if !session.is_valid() {
1704 return Err("account changed during leave".to_string());
1705 }
1706 let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
1710 if !session.is_valid() {
1712 return Err("account changed during leave".to_string());
1713 }
1714 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1715 Ok(())
1716}
1717
1718pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1723 let session = SessionGuard::capture();
1724 assert_current_root(community)?;
1725 let signer = crate::signer::active_signer()?;
1726 let my_pk = me_pk()?;
1727 let authority = fetch_authority(transport, community).await;
1729 let owner_hex = community.owner()?.to_hex();
1730 if !authority.roles.can_act_on_member(
1731 &my_pk.to_hex(),
1732 Some(&owner_hex),
1733 &target.to_hex(),
1734 crate::community::roles::Permissions::KICK,
1735 ) {
1736 return Err("not authorized to kick this member".to_string());
1737 }
1738 let target_hex = target.to_hex();
1750 let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
1751 let may_strip = authority.roles.can_act_on_member(
1752 &my_pk.to_hex(),
1753 Some(&owner_hex),
1754 &target_hex,
1755 crate::community::roles::Permissions::MANAGE_ROLES,
1756 );
1757 if holds_roles && may_strip {
1758 grant_roles(transport, community, target, Vec::new())
1759 .await
1760 .map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
1761 if !session.is_valid() {
1762 return Err("account changed during kick".to_string());
1763 }
1764 }
1765 let at_ms = now_ms();
1766 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1767 let citation = required_authority_citation(community, &my_pk)?;
1770 let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
1771 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
1772 .map_err(|e| e.to_string())?;
1773 if !session.is_valid() {
1774 return Err("account changed before send".to_string());
1775 }
1776 transport.publish(&wrap, &community.relays).await?;
1777 Ok(())
1778}
1779
1780pub struct AuthorityView {
1786 pub roles: crate::community::roles::CommunityRoles,
1787 pub banned: std::collections::BTreeSet<String>,
1788 pub gapped: bool,
1790 pub floored: std::collections::BTreeSet<String>,
1792 pub head_entities: std::collections::BTreeSet<String>,
1794 pub banned_at: std::collections::BTreeMap<String, u64>,
1796}
1797
1798pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1804 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1805 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1806 .unwrap_or_default()
1807 .into_iter()
1808 .filter(|(_, f)| f.0 == community.root_epoch.0)
1809 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1810 .collect();
1811 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1812
1813 let mut editions: Vec<ParsedEdition> = Vec::new();
1814 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1815 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1816 let mut oldest: Option<u64> = None;
1817 let mut until: Option<u64> = None;
1818 let mut a = fold_authority(community, &[], &floors);
1823 for _ in 0..FOLLOW_MAX_PAGES {
1824 let query = Query {
1828 kinds: vec![stream::KIND_WRAP],
1829 authors: vec![control.pk_hex()],
1830 until,
1831 limit: Some(FOLLOW_PAGE),
1832 evidence: crate::community::transport::Evidence::Quorum,
1833 ..Default::default()
1834 };
1835 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
1836 let mut fresh = 0usize;
1837 for w in &wraps {
1838 if !seen_wraps.insert(w.id) {
1839 continue;
1840 }
1841 fresh += 1;
1842 let at = w.created_at.as_secs();
1843 if oldest.is_none_or(|o| at < o) {
1844 oldest = Some(at);
1845 }
1846 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1847 if seen.insert(ed.inner_id) {
1848 editions.push(ed);
1849 }
1850 }
1851 }
1852 a = fold_authority(community, &editions, &floors);
1853 if !a.gapped || fresh == 0 {
1854 break;
1855 }
1856 until = oldest;
1857 }
1858 AuthorityView {
1859 roles: a.roles,
1860 banned: a.banned,
1861 gapped: a.gapped,
1862 floored: floors.keys().cloned().collect(),
1863 head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
1864 banned_at: a.banned_at,
1865 }
1866}
1867
1868async fn fetch_guestbook_events<T: Transport + ?Sized>(
1880 transport: &T,
1881 community: &CommunityV2,
1882 since_secs: u64,
1883) -> Result<(Vec<guestbook::GuestbookEvent>, u64), String> {
1884 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1885 const GB_PAGE: usize = 500;
1886 const GB_MAX_PAGES: usize = 12;
1887 let mut events = Vec::new();
1888 let mut newest: u64 = since_secs;
1889 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1890 let mut until: Option<u64> = None;
1891 let mut oldest: Option<u64> = None;
1892 for _ in 0..GB_MAX_PAGES {
1893 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() };
1896 let wraps = transport.fetch(&query, &community.relays).await?;
1897 let mut fresh = 0usize;
1898 for wrap in &wraps {
1899 if !seen.insert(wrap.id) {
1900 continue;
1901 }
1902 fresh += 1;
1903 let at = wrap.created_at.as_secs();
1904 if oldest.is_none_or(|o| at < o) {
1905 oldest = Some(at);
1906 }
1907 if at > newest {
1908 newest = at;
1909 }
1910 if at < since_secs {
1912 continue;
1913 }
1914 if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
1915 if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
1916 events.push(ev);
1917 }
1918 }
1919 }
1920 if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
1921 break;
1922 }
1923 match oldest {
1924 Some(o) if o > 0 => until = Some(o),
1925 _ => break,
1926 }
1927 }
1928 Ok((events, newest))
1929}
1930
1931fn fold_members(
1936 community: &CommunityV2,
1937 events: &[guestbook::GuestbookEvent],
1938 mut observed: std::collections::BTreeMap<PublicKey, u64>,
1939 roles: &crate::community::roles::CommunityRoles,
1940 banlist: &std::collections::BTreeSet<PublicKey>,
1941 banned_at: &std::collections::BTreeMap<PublicKey, u64>,
1942) -> Result<Vec<PublicKey>, String> {
1943 let owner = community.owner()?;
1944 let owner_hex = owner.to_hex();
1945 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1946
1947 for g in &roles.grants {
1954 if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
1955 observed.entry(pk).or_insert(0);
1956 }
1957 }
1958
1959 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1968 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
1972 let actor_hex = actor.to_hex();
1973 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
1974 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1975 };
1976 let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
1977 let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at);
1978 if !banlist.contains(&owner) {
1980 members.insert(owner);
1981 }
1982 Ok(members.into_iter().collect())
1983}
1984
1985pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
1995 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1996 let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
1997 return false;
1998 };
1999 let Ok(owner) = community.owner() else { return false };
2000 let owner_hex = owner.to_hex();
2001 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2002 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
2003 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2004 let actor_hex = actor.to_hex();
2005 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2006 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2007 };
2008 matches!(
2009 guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
2010 Some(st) if st.verdict == guestbook::Verdict::Kicked
2011 )
2012}
2013
2014pub async fn sync_guestbook<T: Transport + ?Sized>(
2019 transport: &T,
2020 community: &CommunityV2,
2021 session: &SessionGuard,
2022) -> Result<Vec<guestbook::GuestbookEvent>, String> {
2023 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2024 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2025 let since = cursor.saturating_sub(1);
2028 let (fresh, newest) = fetch_guestbook_events(transport, community, since).await?;
2029 if !session.is_valid() {
2030 return Err("account changed during guestbook sync".to_string());
2031 }
2032 let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
2033 let mut added = Vec::new();
2034 for ev in fresh {
2035 if !known.contains(&ev.rumor_id) {
2036 events.push(ev.clone());
2037 added.push(ev);
2038 }
2039 }
2040 if !added.is_empty() || newest > cursor {
2041 crate::db::community::set_guestbook(&cid_hex, &events, newest.max(cursor))?;
2042 }
2043 Ok(added)
2044}
2045
2046pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
2049 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2050 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2051 if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
2052 return Ok(false);
2053 }
2054 events.push(ev);
2055 crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
2056 Ok(true)
2057}
2058
2059pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2066 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2067 let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2068 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2069 for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
2070 if let Ok(pk) = PublicKey::parse(&npub) {
2071 observed.insert(pk, last_active_secs.saturating_mul(1000));
2072 }
2073 }
2074 let roles = crate::db::community::get_community_roles(&cid_hex)?;
2075 let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2076 .unwrap_or_default()
2077 .iter()
2078 .filter_map(|h| PublicKey::from_hex(h).ok())
2079 .collect();
2080 let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
2083 .unwrap_or_default()
2084 .into_iter()
2085 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2086 .collect();
2087 fold_members(community, &events, observed, &roles, &banlist, &banned_at)
2088}
2089
2090pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2096 let (events, _newest) = fetch_guestbook_events(transport, community, 0).await?;
2097 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2100 for ch in &community.channels {
2101 if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
2102 for f in &page {
2103 let e = observed.entry(f.event.opened().author).or_insert(0);
2104 *e = (*e).max(f.event.opened().at_ms);
2105 }
2106 }
2107 }
2108
2109 let authority = fetch_authority(transport, community).await;
2114 let banlist: std::collections::BTreeSet<PublicKey> =
2116 authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
2117 let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
2121 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
2122 )
2123 .unwrap_or_default()
2124 .into_iter()
2125 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2126 .collect();
2127 for (h, at) in &authority.banned_at {
2128 if let Ok(pk) = PublicKey::from_hex(h) {
2129 let slot = banned_at.entry(pk).or_insert(0);
2130 *slot = (*slot).max(*at);
2131 }
2132 }
2133 fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
2134}
2135
2136pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2144 let session = SessionGuard::capture();
2145 let signer = crate::signer::active_signer()?;
2146 let my_pk = me_pk()?;
2147 if community.owner()? != my_pk {
2148 return Err("only the owner can dissolve a community".to_string());
2149 }
2150 let at = now_ms() / 1000;
2151 let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
2152 let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
2153 if !session.is_valid() {
2154 return Err("account changed during dissolve".to_string());
2155 }
2156 transport.publish_durable(&wrap, &community.relays).await?;
2158 crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2159 Ok(())
2160}
2161
2162pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
2167 let group = super::derive::dissolved_group_key(community.id());
2168 let query = Query {
2169 kinds: vec![stream::KIND_WRAP],
2170 authors: vec![group.pk_hex()],
2171 limit: Some(20),
2172 ..Default::default()
2173 };
2174 let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
2175 return false;
2176 };
2177 wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
2178}
2179
2180pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
2193 let session = SessionGuard::capture();
2194 let cid = community.id();
2195 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2196 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2198 return Err("this community has been dissolved; it cannot be re-founded".to_string());
2199 }
2200 let signer = crate::signer::active_signer()?;
2201 let my_pk = me_pk()?;
2202 let lock = super::realtime::follow_lock(cid);
2206 let _guard = lock.lock().await;
2207 let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
2211 let community = &fresh;
2212 let owner = community.owner()?;
2213
2214 {
2221 let owner_hex = owner.to_hex();
2222 let me_hex = my_pk.to_hex();
2223 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2229 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2230 let authorized = my_pk == owner
2231 || (!banned.contains(&me_hex)
2232 && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
2233 && removed.iter().all(|t| {
2234 roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
2235 }));
2236 if !authorized {
2237 return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
2238 }
2239 }
2240
2241 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2245 .into_iter()
2246 .filter(|(_, f)| f.0 == community.root_epoch.0)
2247 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2248 .collect();
2249 let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2250 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2257 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2258 let mut oldest: Option<u64> = None;
2259 let mut until: Option<u64> = None;
2260 let mut truncated = false;
2264 for page in 0..COMPACT_MAX_PAGES {
2265 let query = Query {
2269 kinds: vec![stream::KIND_WRAP],
2270 authors: vec![current_control.pk_hex()],
2271 until,
2272 limit: Some(FOLLOW_PAGE),
2273 evidence: crate::community::transport::Evidence::Full,
2274 ..Default::default()
2275 };
2276 let wraps = transport.fetch(&query, &community.relays).await?;
2277 let mut fresh = 0usize;
2278 for w in &wraps {
2279 if !seen_wraps.insert(w.id) {
2280 continue;
2281 }
2282 fresh += 1;
2283 let at = w.created_at.as_secs();
2284 if oldest.is_none_or(|o| at < o) {
2285 oldest = Some(at);
2286 }
2287 if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
2288 opened.push(parsed);
2289 }
2290 }
2291 if fresh == 0 {
2292 truncated = wraps.len() >= FOLLOW_PAGE;
2296 break;
2297 }
2298 until = oldest;
2299 if page + 1 == COMPACT_MAX_PAGES {
2300 truncated = true;
2301 }
2302 }
2303 if truncated {
2304 return Err(
2305 "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(),
2306 );
2307 }
2308
2309 let prev_epoch = community.root_epoch;
2310 let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
2311 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2312 if !session.is_valid() {
2318 return Err("account changed during re-founding compaction".to_string());
2319 }
2320 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2321 let new_control = control_group_key(&new_root, cid, new_epoch);
2322 let at = now_ms();
2323 let at_secs = at / 1000;
2324
2325 use std::collections::BTreeMap;
2331 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2332 for (i, (e, _)) in opened.iter().enumerate() {
2333 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2334 }
2335 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2336 for (floor_key, floor) in &floors {
2337 let head_idx = by_eid
2345 .get(floor_key)
2346 .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2347 let Some(head_idx) = head_idx else {
2348 return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
2349 };
2350 let (head_ed, head_os) = &opened[head_idx];
2351 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2352 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2353 carried.push((h, rewrapped));
2354 }
2355 if !session.is_valid() {
2356 return Err("account changed during re-founding acquire".to_string());
2357 }
2358
2359 let members = memberlist(transport, community).await?;
2361 let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
2362 let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
2363 if !recipients.iter().any(|p| *p == my_pk) {
2364 recipients.push(my_pk);
2365 }
2366
2367 let mut base_blobs = Vec::new();
2369 for r in &recipients {
2370 base_blobs.push(
2371 super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2372 .await
2373 .map_err(|e| e.to_string())?,
2374 );
2375 }
2376 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2377 let base_chunks =
2378 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())
2379 .await
2380 .map_err(|e| e.to_string())?;
2381
2382 let mut roster_for_channels = fetch_authority(transport, community).await.roles;
2394 {
2395 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2396 for r in cached.roles {
2397 if !roster_for_channels.roles.iter().any(|x| x.role_id == r.role_id) {
2398 roster_for_channels.roles.push(r);
2399 }
2400 }
2401 for g in cached.grants {
2402 if !roster_for_channels.grants.iter().any(|x| x.member == g.member) {
2403 roster_for_channels.grants.push(g);
2404 }
2405 }
2406 }
2407 if !session.is_valid() {
2408 return Err("account changed during re-founding entitlement fetch".to_string());
2409 }
2410 let owner_hex_for_channels = community.owner().ok().map(|o| o.to_hex());
2411 let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
2412 let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
2413 for ch in &community.channels {
2414 let (Some(old_key), true) = (ch.key, ch.private) else { continue };
2415 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
2416 let entitled: Vec<PublicKey> = recipients
2417 .iter()
2418 .copied()
2419 .filter(|r| {
2420 *r == my_pk
2421 || roster_for_channels.is_entitled(owner_hex_for_channels.as_deref(), &r.to_hex(), &ch_hex, &[], &[])
2422 })
2423 .collect();
2424 let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2425 if !session.is_valid() {
2429 return Err("account changed during re-founding channel prepare".to_string());
2430 }
2431 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)?;
2432 let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
2433 let mut ch_blobs = Vec::new();
2434 for r in &entitled {
2435 ch_blobs.push(
2436 super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
2437 .await
2438 .map_err(|e| e.to_string())?,
2439 );
2440 }
2441 let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
2442 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())
2443 .await
2444 .map_err(|e| e.to_string())?;
2445 channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
2446 channel_chunk_sets.push(ch_chunks);
2447 }
2448 if !session.is_valid() {
2449 return Err("account changed during re-founding prepare".to_string());
2450 }
2451
2452 for c in &base_chunks {
2455 transport.publish_durable(c, &community.relays).await?;
2456 }
2457 for set in &channel_chunk_sets {
2458 for c in set {
2459 transport.publish_durable(c, &community.relays).await?;
2460 }
2461 }
2462 for (_, wrap) in &carried {
2463 transport.publish_durable(wrap, &community.relays).await?;
2464 }
2465 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2468 let snap_id = crate::community::random_32();
2469 for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
2470 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
2471 let _ = transport.publish(&wrap, &community.relays).await;
2472 }
2473 }
2474
2475 if !session.is_valid() {
2477 return Err("account changed during re-founding commit".to_string());
2478 }
2479 if crate::db::community::community_protocol(cid)?.is_none() {
2480 return Ok(community.clone()); }
2482 let mut updated = community.clone();
2486 updated.community_root = new_root;
2487 updated.root_epoch = new_epoch;
2488 for (id, key, ep) in &channel_updates {
2489 if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
2490 c.key = Some(*key);
2491 c.epoch = *ep;
2492 }
2493 }
2494 crate::db::community::save_community_v2(&updated)?;
2495 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2500 for (h, _) in &carried {
2501 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2502 }
2503 if let Some(client) = crate::state::nostr_client() {
2509 super::realtime::refresh_subscription(&client).await;
2510 }
2511 for attempt in 0..3u8 {
2517 match refresh_public_links(transport, &updated).await {
2518 Ok(()) => break,
2519 Err(_) if !session.is_valid() => break, Err(e) if attempt == 2 => {
2521 crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
2522 }
2523 Err(_) => continue,
2524 }
2525 }
2526 Ok(updated)
2527}
2528
2529pub async fn refound_at_birth<T: Transport + ?Sized>(
2545 transport: &T,
2546 community: &CommunityV2,
2547 snapshot_members: &[PublicKey],
2548) -> Result<CommunityV2, String> {
2549 let session = SessionGuard::capture();
2550 let cid = community.id();
2551 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2552 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2555 return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
2556 }
2557 let signer = crate::signer::active_signer()?;
2558 let my_pk = me_pk()?;
2559 if my_pk != community.owner()? {
2560 return Err("only the owner can birth-refound the migration twin".to_string());
2561 }
2562 let lock = super::realtime::follow_lock(cid);
2563 let _guard = lock.lock().await;
2564 let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
2565 if community.root_epoch.0 == 1 {
2571 return Ok(community);
2572 }
2573 if community.root_epoch.0 != 0 {
2574 return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
2575 }
2576 let community = &community;
2577
2578 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2582 .into_iter()
2583 .filter(|(_, f)| f.0 == community.root_epoch.0)
2584 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2585 .collect();
2586 let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2587 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2588 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2589 let mut oldest: Option<u64> = None;
2590 let mut until: Option<u64> = None;
2591 let mut truncated = false;
2593 for page in 0..COMPACT_MAX_PAGES {
2594 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() };
2595 let wraps = transport.fetch(&query, &community.relays).await?;
2596 let mut fresh = 0usize;
2597 for w in &wraps {
2598 if !seen_wraps.insert(w.id) { continue; }
2599 fresh += 1;
2600 let at = w.created_at.as_secs();
2601 if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
2602 if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
2603 opened.push(parsed);
2604 }
2605 }
2606 if fresh == 0 {
2607 truncated = wraps.len() >= FOLLOW_PAGE;
2608 break;
2609 }
2610 until = oldest;
2611 if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
2612 }
2613 if truncated {
2614 return Err(
2615 "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(),
2616 );
2617 }
2618
2619 let prev_epoch = community.root_epoch; let new_epoch = Epoch(1);
2621 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2622 if !session.is_valid() {
2623 return Err("account changed during birth-refound compaction".to_string());
2624 }
2625 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2626 let new_control = control_group_key(&new_root, cid, new_epoch);
2627 let at = now_ms();
2628 let at_secs = at / 1000;
2629
2630 use std::collections::BTreeMap;
2631 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2632 for (i, (e, _)) in opened.iter().enumerate() {
2633 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2634 }
2635 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2636 for (floor_key, floor) in &floors {
2637 let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2638 let Some(head_idx) = head_idx else {
2639 return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
2640 };
2641 let (head_ed, head_os) = &opened[head_idx];
2642 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2643 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2644 carried.push((h, rewrapped));
2645 }
2646 if !session.is_valid() {
2647 return Err("account changed during birth-refound acquire".to_string());
2648 }
2649
2650 let base_blobs = vec![
2652 super::rekey::build_blob(&signer, &my_pk.to_bytes(), &my_pk, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2653 .await
2654 .map_err(|e| e.to_string())?,
2655 ];
2656 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2657 let base_chunks =
2658 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())
2659 .await
2660 .map_err(|e| e.to_string())?;
2661 if !session.is_valid() {
2662 return Err("account changed during birth-refound prepare".to_string());
2663 }
2664
2665 for c in &base_chunks {
2667 transport.publish_durable(c, &community.relays).await?;
2668 }
2669 for (_, wrap) in &carried {
2670 transport.publish_durable(wrap, &community.relays).await?;
2671 }
2672 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2680 let snap_id = crate::community::random_32();
2681 let snapshot_wraps: Vec<Event> = {
2682 let mut out = Vec::new();
2683 for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
2684 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
2685 .await
2686 .map_err(|e| format!("seal birth snapshot: {e}"))?;
2687 out.push(wrap);
2688 }
2689 out
2690 };
2691 for wrap in &snapshot_wraps {
2692 transport.publish_durable(wrap, &community.relays).await?;
2693 }
2694 let verify_view = {
2703 let mut v = community.clone();
2704 v.community_root = new_root;
2705 v.root_epoch = new_epoch;
2706 v
2707 };
2708 let expected: Vec<PublicKey> = {
2709 let banlist = fetch_authority(transport, &verify_view).await.banned;
2710 snapshot_members.iter().copied()
2711 .filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
2712 .collect()
2713 };
2714 if !expected.is_empty() {
2715 let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
2716 let missing = expected.iter().filter(|m| !folded.contains(m)).count();
2717 if missing > 0 {
2718 return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
2719 }
2720 }
2721
2722 if !session.is_valid() {
2724 return Err("account changed during birth-refound commit".to_string());
2725 }
2726 if crate::db::community::community_protocol(cid)?.is_none() {
2727 return Ok(community.clone());
2728 }
2729 let mut updated = community.clone();
2730 updated.community_root = new_root;
2731 updated.root_epoch = new_epoch;
2732 crate::db::community::save_community_v2(&updated)?;
2733 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2734 for (h, _) in &carried {
2735 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2736 }
2737 Ok(updated)
2738}
2739
2740fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
2747 if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
2748 return Ok(existing);
2749 }
2750 let fresh = crate::community::random_32();
2751 crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
2752 Ok(fresh)
2753}
2754
2755fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
2761 let hex = crate::simd::hex::bytes_to_hex_32;
2762 let channels = community
2763 .channels
2764 .iter()
2765 .filter(|c| c.private)
2766 .filter_map(|c| {
2770 c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: Some(hex(&k)), epoch: c.epoch.0, name: c.name.clone() })
2771 })
2772 .collect();
2773 super::list::JoinMaterial {
2774 community_id: hex(&community.identity.community_id.0),
2775 owner: hex(&community.identity.owner_xonly),
2776 owner_salt: hex(&community.identity.owner_salt),
2777 community_root: hex(&community.community_root),
2778 root_epoch: community.root_epoch.0,
2779 channels,
2780 relays: community.relays.clone(),
2781 name: community.name.clone(),
2782 extra: Default::default(),
2783 }
2784}
2785
2786fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
2790 let channels = jm
2793 .channels
2794 .iter()
2795 .filter_map(|c| {
2796 c.key.as_ref().map(|k| invite::ChannelGrant { id: c.id.clone(), key: k.clone(), epoch: c.epoch, name: c.name.clone() })
2797 })
2798 .collect();
2799 CommunityInvite {
2800 community_id: jm.community_id.clone(),
2801 owner: jm.owner.clone(),
2802 owner_salt: jm.owner_salt.clone(),
2803 community_root: jm.community_root.clone(),
2804 root_epoch: jm.root_epoch,
2805 channels,
2806 relays: jm.relays.clone(),
2807 name: jm.name.clone(),
2808 icon: None,
2809 expires_at: None,
2810 creator_npub: None,
2811 label: None,
2812 extra: Default::default(),
2813 }
2814}
2815
2816fn held_v2_relays() -> Vec<String> {
2819 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2820 if let Ok(ids) = crate::db::community::list_community_ids() {
2821 for id in ids {
2822 if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2823 if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
2824 set.extend(c.relays);
2825 }
2826 }
2827 }
2828 }
2829 set.into_iter().collect()
2830}
2831
2832async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
2838 let signer = crate::signer::active_signer()?;
2839 let my_pk = me_pk()?;
2840 let query = Query {
2841 kinds: vec![super::kind::COMMUNITY_LIST],
2842 authors: vec![my_pk.to_hex()],
2843 limit: Some(4),
2844 ..Default::default()
2845 };
2846 let events = transport.fetch(&query, relays).await?;
2847 let seen = events.len();
2848 let mut undecryptable = 0usize;
2852 let mut unreadable: Option<(u64, String, usize, String)> = None;
2853 let mut best: Option<(u64, String, super::list::CommunityList)> = None;
2854 for e in events {
2855 let at = e.created_at.as_secs();
2856 let id_hex = e.id.to_hex();
2857 let content_len = e.content.len();
2858 match super::list::parse_list_event_signed(&signer, my_pk, &e).await {
2859 Ok(l) => {
2860 if best.as_ref().map(|(b, _, _)| at > *b).unwrap_or(true) {
2861 best = Some((at, id_hex, l));
2862 }
2863 }
2864 Err(err) => {
2865 undecryptable += 1;
2866 if unreadable.as_ref().map(|(a, _, _, _)| at > *a).unwrap_or(true) {
2867 unreadable = Some((at, id_hex, content_len, err.to_string()));
2868 }
2869 }
2870 }
2871 }
2872 if let Some((at, id, len, err)) = &unreadable {
2876 if best.as_ref().map(|(b, _, _)| at > b).unwrap_or(true) {
2877 crate::log_net_fail!(
2878 "[CommunityList] IGNORED a newer copy {} created_at={at} ({len} content bytes) — falling back to stale membership: {err}",
2879 &id[..8]
2880 );
2881 }
2882 }
2883 if let Some((at, id, l)) = &best {
2884 let bytes = serde_json::to_string(l).map(|s| s.len()).unwrap_or(0);
2885 crate::log_debug!(
2886 "[CommunityList] using {} created_at={at} ({bytes}/{} bytes) of {seen} copies, {undecryptable} unreadable",
2887 &id[..8],
2888 super::stream::NIP44_MAX_PLAINTEXT
2889 );
2890 }
2891 Ok(best.map(|(_, _, l)| l))
2892}
2893
2894pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
2906 let session = SessionGuard::capture();
2907 let signer = crate::signer::active_signer()?;
2908 let my_pk = me_pk()?;
2909 let relays = held_v2_relays();
2910 if relays.is_empty() {
2911 return Ok(false); }
2913 let remote = match fetch_community_list(transport, &relays).await {
2917 Ok(r) => r.unwrap_or_default(),
2918 Err(e) => {
2919 crate::log_warn!(
2925 "[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
2926 e,
2927 just_joined
2928 .map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
2929 .unwrap_or_default()
2930 );
2931 return Ok(false);
2932 }
2933 };
2934 let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
2935 let now = now_ms();
2936 let mut local = super::list::CommunityList::default();
2937 for id in crate::db::community::list_community_ids()? {
2938 if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2939 continue;
2940 }
2941 let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
2942 let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
2943 let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
2944 let tombstoned_at = remote
2956 .tombstones
2957 .iter()
2958 .find(|t| t.community_id == cid_hex)
2959 .map(|t| t.removed_at)
2960 .unwrap_or(0);
2961 let held_since = c.created_at_ms;
2962 if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
2963 crate::log_warn!(
2964 "[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
2965 &cid_hex[..8], tombstoned_at, held_since
2966 );
2967 continue;
2968 }
2969 let added_at = if remote.is_live(&cid_hex) && !is_join {
2974 remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
2975 } else if !is_join && tombstoned_at > 0 {
2976 held_since
2977 } else {
2978 now
2979 };
2980 let jm = join_material(&c);
2981 local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
2982 }
2983 let merged = remote.merge(&local);
2984 merged.assert_fits().map_err(|e| e.to_string())?;
2985 let event = super::list::build_list_event_signed(&signer, my_pk, &merged).await.map_err(|e| e.to_string())?;
2986 if !session.is_valid() {
2987 return Err("account changed during community-list publish".to_string());
2988 }
2989 if let Err(e) = transport.publish(&event, &relays).await {
2990 crate::log_warn!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
2991 return Err(e);
2992 }
2993 Ok(true)
2994}
2995
2996const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
3001
3002pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
3015 if crate::state::nostr_client().is_none() {
3016 return;
3017 }
3018 let session = SessionGuard::capture();
3019 tokio::spawn(async move {
3020 for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
3021 if !session.is_valid() {
3022 return;
3023 }
3024 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3025 match republish_community_list(&transport, just_joined.as_ref()).await {
3026 Ok(true) => {
3027 if attempt > 0 {
3028 crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
3029 }
3030 return;
3031 }
3032 Ok(false) => {} Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
3034 }
3035 tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
3036 }
3037 crate::log_warn!(
3038 "[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
3039 LIST_REPUBLISH_BACKOFF_SECS.len()
3040 );
3041 });
3042}
3043
3044async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
3047 let signer = crate::signer::active_signer()?;
3048 let my_pk = me_pk()?;
3049 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3050 let mut doc = match fetch_community_list(transport, relays).await {
3054 Ok(d) => d.unwrap_or_default(),
3055 Err(e) => return Err(e),
3056 };
3057 let now = now_ms();
3058 doc.tombstones.retain(|t| t.community_id != cid_hex);
3059 doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
3060 doc.assert_fits().map_err(|e| e.to_string())?;
3061 let event = super::list::build_list_event_signed(&signer, my_pk, &doc).await.map_err(|e| e.to_string())?;
3062 transport.publish(&event, relays).await
3063}
3064
3065pub struct ListSyncOutcome {
3072 pub joined: Vec<CommunityV2>,
3074 pub removed: Vec<(String, Vec<String>)>,
3081}
3082
3083pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
3084 let session = SessionGuard::capture();
3085 let mut relays = held_v2_relays();
3086 relays.extend(bootstrap_relays.iter().cloned());
3087 relays.sort();
3088 relays.dedup();
3089 if relays.is_empty() {
3090 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3091 }
3092 let list = match fetch_community_list(transport, &relays).await {
3096 Ok(Some(l)) => {
3097 crate::log_debug!(
3098 "[CommunityList] fetched: {} entries, {} tombstones, across {} relays",
3099 l.entries.len(),
3100 l.tombstones.len(),
3101 relays.len()
3102 );
3103 l
3104 }
3105 Ok(None) => {
3106 crate::log_debug!("[CommunityList] no kind-13302 across {} relays", relays.len());
3110 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3111 }
3112 Err(e) => {
3113 crate::log_net_fail!("[CommunityList] fetch failed across {} relays: {e}", relays.len());
3114 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3115 }
3116 };
3117 let mut removed: Vec<(String, Vec<String>)> = Vec::new();
3123 for t in &list.tombstones {
3124 if list.is_live(&t.community_id) {
3125 continue;
3126 }
3127 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
3128 let id = crate::community::CommunityId(cid);
3129 let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
3130 continue; };
3132 if held.created_at_ms > t.removed_at {
3138 crate::log_warn!(
3139 "[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
3140 &t.community_id[..8], t.removed_at, held.created_at_ms
3141 );
3142 continue;
3143 }
3144 if !session.is_valid() {
3145 return Err("account changed during community-list sync".to_string());
3146 }
3147 let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
3148 let _ = crate::db::community::delete_community(&t.community_id);
3149 removed.push((t.community_id.clone(), channel_ids));
3150 }
3151 let mut joined = Vec::new();
3152 for entry in list.live_entries() {
3153 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
3154 if crate::db::community::load_community_v2(&crate::community::CommunityId(cid)).ok().flatten().is_some() {
3155 continue; }
3157 if !session.is_valid() {
3158 return Err("account changed during community-list sync".to_string());
3159 }
3160 let bundle = material_to_invite(&entry.current);
3165 match accept_bundle(transport, &session, &bundle, None, false).await {
3166 Ok(community) => joined.push(community),
3167 Err(e) => crate::log_net_fail!(
3171 "[CommunityList] {} is listed but adoption failed: {e}",
3172 &entry.community_id[..entry.community_id.len().min(8)]
3173 ),
3174 }
3175 }
3176 Ok(ListSyncOutcome { joined, removed })
3177}
3178
3179pub(super) fn citation_is_synced(
3209 cid_hex: &str,
3210 owner_hex: &str,
3211 actor_hex: &str,
3212 citation: Option<&crate::community::edition::AuthorityCitation>,
3213) -> bool {
3214 if owner_hex == actor_hex {
3215 return true;
3216 }
3217 if citation.is_none() {
3218 return false;
3219 }
3220 let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
3221 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
3222 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
3223 &crate::community::CommunityId(cid_bytes),
3224 &actor_bytes,
3225 ));
3226 let head: Vec<crate::community::roster::EntityHead> =
3227 crate::db::community::get_edition_head(cid_hex, &grant_hex)
3228 .ok()
3229 .flatten()
3230 .map(|(version, self_hash)| crate::community::roster::EntityHead {
3231 entity_hex: grant_hex.clone(),
3232 version,
3233 self_hash,
3234 inner_id: [0u8; 32],
3235 citation: None,
3236 })
3237 .into_iter()
3238 .collect();
3239 crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
3240}
3241
3242fn required_authority_citation(
3250 community: &CommunityV2,
3251 actor: &PublicKey,
3252) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
3253 if community.owner().ok().as_ref() == Some(actor) {
3254 return Ok(None); }
3256 my_authority_citation(community, actor).map(Some).ok_or_else(|| {
3257 "your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
3258 })
3259}
3260
3261fn my_authority_citation(
3262 community: &CommunityV2,
3263 actor: &PublicKey,
3264) -> Option<crate::community::edition::AuthorityCitation> {
3265 if community.owner().ok().as_ref() == Some(actor) {
3266 return None;
3267 }
3268 let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
3269 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3270 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
3271 crate::db::community::get_edition_head(&cid_hex, &entity_hex)
3272 .ok()
3273 .flatten()
3274 .map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
3275}
3276
3277fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
3285 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3286 match crate::db::community::get_server_root_epoch(&cid_hex)? {
3287 Some(held) if held != community.root_epoch.0 => Err(format!(
3288 "the community re-founded mid-action (epoch {} -> {held}); retry",
3289 community.root_epoch.0
3290 )),
3291 _ => Ok(()), }
3293}
3294
3295async fn publish_control_edition<T: Transport + ?Sized>(
3296 transport: &T,
3297 community: &CommunityV2,
3298 session: &SessionGuard,
3299 vsk: &str,
3300 entity_id: &[u8; 32],
3301 content: &str,
3302) -> Result<(), String> {
3303 assert_current_root(community)?;
3304 let signer = crate::signer::active_signer()?;
3305 let my_pk = me_pk()?;
3306 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
3307 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3308 let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
3309 let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
3310 Some((v, h)) => (v + 1, Some(h)),
3311 None => (1, None),
3312 };
3313 let citation = required_authority_citation(community, &my_pk)?;
3319 let at = now_ms() / 1000;
3320 let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
3321 let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
3322 if !session.is_valid() {
3323 return Err("account changed before control publish".to_string());
3324 }
3325 transport.publish(&wrap, &community.relays).await?;
3326 if !session.is_valid() {
3332 return Ok(());
3333 }
3334 if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
3335 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
3336 }
3337 Ok(())
3338}
3339
3340fn merge_local_roster(cid_hex: &str, role: Option<&crate::community::roles::Role>, grant: Option<&crate::community::roles::MemberGrant>) {
3349 let mut roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
3350 if let Some(r) = role {
3351 match roster.roles.iter_mut().find(|x| x.role_id == r.role_id) {
3352 Some(slot) => *slot = r.clone(),
3353 None => roster.roles.push(r.clone()),
3354 }
3355 }
3356 if let Some(g) = grant {
3357 match roster.grants.iter_mut().find(|x| x.member == g.member) {
3358 Some(slot) => *slot = g.clone(),
3359 None => roster.grants.push(g.clone()),
3360 }
3361 }
3362 let at = crate::db::community::get_community_roles_at(cid_hex).unwrap_or(0);
3363 if let Err(e) = crate::db::community::set_community_roles(cid_hex, &roster, at) {
3364 crate::log_warn!("v2: local roster merge failed (heals on the next control fold): {e}");
3365 }
3366}
3367
3368pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
3372 let session = SessionGuard::capture();
3373 super::roles::validate_role(role)?;
3374 let content = super::roles::role_content_json(role)?;
3375 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
3376 publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content).await
3377}
3378
3379pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
3383 let session = SessionGuard::capture();
3384 let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
3385 let content = super::roles::grant_content_json(&grant)?;
3386 let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
3387 publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content).await
3388}
3389
3390pub async fn ensure_admin_role<T: Transport + ?Sized>(
3395 transport: &T,
3396 community: &CommunityV2,
3397 view: &AuthorityView,
3398 create_if_missing: bool,
3399) -> Result<Option<String>, String> {
3400 use crate::community::roles::{Permissions, Role, RoleScope};
3401 if let Some(r) = view
3402 .roles
3403 .roles
3404 .iter()
3405 .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_ALL))
3406 {
3407 return Ok(Some(r.role_id.clone()));
3408 }
3409 if !create_if_missing {
3410 return Ok(None);
3411 }
3412 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3413 let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
3414 set_role(transport, community, &Role::admin(role_id.clone())).await?;
3415 Ok(Some(role_id))
3416}
3417
3418pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3425 let session = SessionGuard::capture();
3428 let my_pk = me_pk()?;
3429 if my_pk != community.owner()? {
3430 return Err("only the community owner can grant @admin".to_string());
3431 }
3432 let view = fetch_authority(transport, community).await;
3433 if !session.is_valid() {
3434 return Err("account changed during grant".to_string());
3435 }
3436 let member_hex = member.to_hex();
3437 require_grant_head(community, &view, &member_hex)?;
3438 let role_id = ensure_admin_role(transport, community, &view, true)
3439 .await?
3440 .expect("create_if_missing yields an id");
3441 let mut role_ids = view
3442 .roles
3443 .grants
3444 .iter()
3445 .find(|g| g.member == member_hex)
3446 .map(|g| g.role_ids.clone())
3447 .unwrap_or_default();
3448 if role_ids.contains(&role_id) {
3449 return Ok(()); }
3451 role_ids.push(role_id);
3452 grant_roles(transport, community, member, role_ids).await
3453}
3454
3455pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3458 let session = SessionGuard::capture();
3459 let my_pk = me_pk()?;
3460 if my_pk != community.owner()? {
3461 return Err("only the community owner can revoke @admin".to_string());
3462 }
3463 let view = fetch_authority(transport, community).await;
3464 if !session.is_valid() {
3465 return Err("account changed during revoke".to_string());
3466 }
3467 let member_hex = member.to_hex();
3468 require_grant_head(community, &view, &member_hex)?;
3469 let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
3470 return Ok(()); };
3472 let mut role_ids = view
3473 .roles
3474 .grants
3475 .iter()
3476 .find(|g| g.member == member_hex)
3477 .map(|g| g.role_ids.clone())
3478 .unwrap_or_default();
3479 let before = role_ids.len();
3480 role_ids.retain(|r| r != &role_id);
3481 if role_ids.len() == before {
3482 return Ok(());
3483 }
3484 grant_roles(transport, community, member, role_ids).await
3485}
3486
3487fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
3491 let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
3492 return Err("malformed member key".to_string());
3493 };
3494 let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
3495 if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
3496 return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
3497 }
3498 Ok(())
3499}
3500
3501pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
3504 let session = SessionGuard::capture();
3505 super::roles::validate_banlist(banned)?;
3506 let content = super::roles::banlist_content_json(banned)?;
3507 let eid = super::derive::banlist_locator(community.id());
3508 publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content).await
3509}
3510
3511pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
3514 let session = SessionGuard::capture();
3515 control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
3516 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3517 publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
3518}
3519
3520pub async fn persist_community_image(
3525 id: &crate::community::CommunityId,
3526 img: control::ImageRef,
3527 is_banner: bool,
3528 session: &SessionGuard,
3529) -> Option<CommunityV2> {
3530 let lock = super::realtime::follow_lock(id);
3531 let _guard = lock.lock().await;
3532 if !session.is_valid() {
3533 return None;
3534 }
3535 let mut fresh = crate::db::community::load_community_v2(id).ok()??;
3536 if is_banner {
3537 fresh.banner = Some(img);
3538 } else {
3539 fresh.icon = Some(img);
3540 }
3541 crate::db::community::save_community_v2(&fresh).ok()?;
3542 Some(fresh)
3543}
3544
3545pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
3548 let session = SessionGuard::capture();
3549 let my_pk = me_pk()?;
3550 ensure_channel_manager(community, &my_pk)?;
3551 let old_name = community.channel(channel_id).map(|c| c.name.clone());
3552 if meta.private {
3557 if let Some(held) = community.channel(channel_id) {
3558 if !held.private {
3559 return Err("converting a public channel to private is not supported yet".to_string());
3560 }
3561 }
3562 }
3563 control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
3564 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3565 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3566 if !session.is_valid() {
3570 return Ok(());
3571 }
3572 if let Ok(Some(mut held)) = crate::db::community::load_community_v2(community.id()) {
3573 if let Some(ch) = held.channels.iter_mut().find(|c| c.id.0 == channel_id.0) {
3574 ch.name = meta.name.clone();
3575 ch.private = meta.private;
3576 ch.voice = meta.voice;
3577 ch.meta_custom = meta.custom.clone();
3578 ch.meta_extra = meta.extra.clone();
3579 crate::db::community::save_community_v2(&held)?;
3580 }
3581 }
3582 if meta.private {
3584 if let Some(old) = old_name.filter(|o| *o != meta.name) {
3585 rename_channel_access_role(transport, community, channel_id, &old, &meta.name, &session).await;
3586 }
3587 }
3588 Ok(())
3589}
3590
3591async fn rename_channel_access_role<T: Transport + ?Sized>(
3598 transport: &T,
3599 community: &CommunityV2,
3600 channel_id: &ChannelId,
3601 old_name: &str,
3602 new_name: &str,
3603 session: &SessionGuard,
3604) {
3605 let (Ok(my_pk), Ok(owner)) = (me_pk(), community.owner()) else {
3606 return;
3607 };
3608 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3609 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3610 let mut roster = fetch_authority(transport, community).await.roles;
3613 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3614 for r in cached.roles {
3615 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
3616 roster.roles.push(r);
3617 }
3618 }
3619 if !session.is_valid() {
3620 return;
3621 }
3622 let (me_hex, owner_hex) = (my_pk.to_hex(), owner.to_hex());
3626 if !roster.is_authorized_in(&me_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
3627 return;
3628 }
3629 let Some(mut role) = roster
3632 .channel_roles(&chan_hex)
3633 .into_iter()
3634 .find(|r| r.permissions == crate::community::roles::Permissions::empty() && r.name == old_name)
3635 .cloned()
3636 else {
3637 return;
3638 };
3639 if !roster.can_act_on_position(&me_hex, Some(&owner_hex), role.position, crate::community::roles::Permissions::MANAGE_ROLES) {
3640 return;
3641 }
3642 role.name = new_name.to_string();
3643 if let Err(e) = set_role(transport, community, &role).await {
3644 crate::log_warn!("v2: channel renamed but its access role did not follow: {e}");
3645 return;
3646 }
3647 if session.is_valid() {
3648 merge_local_roster(&cid_hex, Some(&role), None);
3649 }
3650}
3651
3652fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
3658 let owner = community.owner()?;
3659 if *me == owner {
3660 return Ok(());
3661 }
3662 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3663 let me_hex = me.to_hex();
3664 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
3665 return Err("you are banned from this community".to_string());
3666 }
3667 let roster = crate::db::community::get_community_roles(&cid_hex)?;
3668 if roster.is_authorized(&me_hex, Some(&owner.to_hex()), crate::community::roles::Permissions::MANAGE_CHANNELS) {
3669 Ok(())
3670 } else {
3671 Err("managing channels here needs the MANAGE_CHANNELS permission".to_string())
3672 }
3673}
3674
3675pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3681 let channel_id = ChannelId(super::super::random_32());
3682 create_public_channel_with_id(transport, community, name, channel_id).await?;
3683 Ok(channel_id)
3684}
3685
3686pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3690 let session = SessionGuard::capture();
3691 let lock = super::realtime::follow_lock(community.id());
3695 let _guard = lock.lock().await;
3696 let my_pk = me_pk()?;
3697 ensure_channel_manager(community, &my_pk)?;
3698 assert_channel_id_free(&channel_id, community.id())?;
3699 let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
3700 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3701 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3702 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3703 if !session.is_valid() {
3704 return Err("account changed during channel create".to_string());
3705 }
3706 let mut updated = community.clone();
3708 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() });
3709 crate::db::community::save_community_v2(&updated)?;
3710 Ok(())
3711}
3712
3713fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
3718 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3719 if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
3720 let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3721 let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
3722 if existing != mine
3723 && matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
3724 {
3725 return Err("channel id is already live in another v2 community".to_string());
3726 }
3727 }
3728 Ok(())
3729}
3730
3731pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3743 let channel_id = ChannelId(super::super::random_32());
3744 create_private_channel_with_id(transport, community, name, channel_id).await?;
3745 Ok(channel_id)
3746}
3747
3748pub fn channel_access_role(channel_id: &ChannelId, name: &str) -> crate::community::roles::Role {
3753 use crate::community::roles::{Permissions, Role, RoleScope};
3754 Role {
3755 role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
3756 name: name.to_string(),
3757 position: u32::MAX - 1,
3758 permissions: Permissions::empty(),
3759 scope: RoleScope::Channel(crate::simd::hex::bytes_to_hex_32(&channel_id.0)),
3760 color: 0,
3761 }
3762}
3763
3764pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3767 let session = SessionGuard::capture();
3768 let lock = super::realtime::follow_lock(community.id());
3772 let _guard = lock.lock().await;
3773 let signer = crate::signer::active_signer()?;
3774 let my_pk = me_pk()?;
3775 ensure_channel_manager(community, &my_pk)?;
3776 assert_channel_id_free(&channel_id, community.id())?;
3777 let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
3778 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3779 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3780
3781 let channel_key = super::super::random_32();
3782 let epoch = Epoch(1);
3783
3784 let access_role = channel_access_role(&channel_id, name);
3787 let access_role_ids = vec![access_role.role_id.clone()];
3788
3789 let owner = community.owner()?;
3793 let mut recipients = vec![my_pk];
3794 if owner != my_pk {
3795 recipients.push(owner);
3796 }
3797 let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
3798 let mut blobs = Vec::with_capacity(recipients.len());
3799 for r in &recipients {
3800 blobs.push(
3801 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
3802 .await
3803 .map_err(|e| e.to_string())?,
3804 );
3805 }
3806 let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
3807 let at_secs = now_ms() / 1000;
3808 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())
3809 .await
3810 .map_err(|e| e.to_string())?;
3811 if !session.is_valid() {
3812 return Err("account changed during channel create".to_string());
3813 }
3814 for c in &chunks {
3815 transport.publish_durable(c, &community.relays).await?;
3816 }
3817 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3818 if !session.is_valid() {
3819 return Err("account changed during channel create".to_string());
3820 }
3821 set_role(transport, community, &access_role).await?;
3826 grant_roles(transport, community, &my_pk, access_role_ids.clone()).await?;
3827 if !session.is_valid() {
3828 return Err("account changed during channel create".to_string());
3829 }
3830 merge_local_roster(
3833 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
3834 Some(&access_role),
3835 Some(&crate::community::roles::MemberGrant { member: my_pk.to_hex(), role_ids: access_role_ids }),
3836 );
3837 if crate::db::community::community_protocol(community.id())?.is_none() {
3839 return Err("community removed during channel create".to_string());
3840 }
3841 let mut updated = community.clone();
3842 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() });
3843 crate::db::community::save_community_v2(&updated)?;
3844 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3847 crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
3848 let _ = refresh_public_links(transport, &updated).await;
3851 Ok(())
3852}
3853
3854#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3859pub enum VendVerdict {
3860 Accept,
3862 Park(&'static str),
3866 Refuse(&'static str),
3868}
3869
3870pub fn judge_channel_key_vend(
3882 community: &CommunityV2,
3883 roster: &crate::community::roles::CommunityRoles,
3884 channel_id: &ChannelId,
3885 epoch: Epoch,
3886 sender_hex: &str,
3887) -> VendVerdict {
3888 let me = match me_pk() {
3889 Ok(pk) => pk.to_hex(),
3890 Err(_) => return VendVerdict::Park("no active identity"),
3891 };
3892 let owner_hex = match community.owner() {
3893 Ok(o) => o.to_hex(),
3894 Err(_) => return VendVerdict::Refuse("community has no resolvable owner"),
3895 };
3896 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3897
3898 let Some(ch) = community.channel(channel_id) else {
3901 return VendVerdict::Park("channel not in our fold yet");
3902 };
3903 if !ch.private {
3904 return VendVerdict::Refuse("vend names a channel our fold says is public");
3907 }
3908
3909 if ch.key.is_some() && epoch.0 <= ch.epoch.0 {
3917 return VendVerdict::Refuse("superseded: we already hold this epoch or newer");
3918 }
3919 if epoch.0 > ch.epoch.0.saturating_add(MAX_VEND_EPOCH_LEAD) {
3920 return VendVerdict::Refuse("vend epoch is implausibly far ahead of the channel head");
3921 }
3922
3923 if !roster.is_entitled(Some(&owner_hex), &me, &chan_hex, &[], &[]) {
3927 return VendVerdict::Park("our grant for this channel has not folded yet");
3928 }
3929
3930 if sender_hex != owner_hex && !roster.is_entitled(Some(&owner_hex), sender_hex, &chan_hex, &[], &[]) {
3933 return VendVerdict::Park("vendor's entitlement has not folded yet");
3934 }
3935
3936 VendVerdict::Accept
3937}
3938
3939const PARKED_VEND_TTL_SECS: u64 = 30 * 24 * 3600;
3942
3943const MAX_VEND_EPOCH_LEAD: u64 = 1024;
3947
3948pub fn absorb_parked_channel_keys(community: &CommunityV2, session: &SessionGuard) -> Vec<ChannelId> {
3954 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3955 let parked = match crate::db::community::get_pending_channel_keys(&cid_hex) {
3956 Ok(p) if !p.is_empty() => p,
3957 _ => return Vec::new(),
3958 };
3959 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3960 let mut adopted = Vec::new();
3961 let now = now_ms() / 1000;
3962 for p in parked {
3963 if adopted.iter().any(|c: &ChannelId| crate::simd::hex::bytes_to_hex_32(&c.0) == p.channel_id) {
3967 let _ = crate::db::community::drop_pending_channel_key(p.id);
3968 continue;
3969 }
3970 if now.saturating_sub(p.received_at.max(0) as u64) > PARKED_VEND_TTL_SECS {
3976 let _ = crate::db::community::drop_pending_channel_key(p.id);
3977 continue;
3978 }
3979 let Some(id_bytes) = crate::simd::hex::hex_to_bytes_32_checked(&p.channel_id) else {
3980 let _ = crate::db::community::drop_pending_channel_key(p.id);
3981 continue;
3982 };
3983 let channel_id = ChannelId(id_bytes);
3984 match judge_channel_key_vend(community, &roster, &channel_id, Epoch(p.epoch), &p.sender) {
3985 VendVerdict::Accept => {
3986 if !session.is_valid() {
3987 return adopted;
3988 }
3989 let keyless = community.channel(&channel_id).is_some_and(|c| c.key.is_none());
3994 let seated = if keyless {
3995 crate::db::community::seat_channel_key(&cid_hex, &p.channel_id, p.epoch, &p.key)
3996 } else {
3997 crate::db::community::advance_channel_epoch(&cid_hex, &p.channel_id, p.epoch, &p.key).map(|_| ())
3998 };
3999 if let Err(e) = seated {
4000 crate::log_warn!("v2: adopting a vended channel key failed: {e}");
4001 continue;
4002 }
4003 let _ = crate::db::community::drop_pending_channel_keys_for(&cid_hex, &p.channel_id);
4005 adopted.push(channel_id);
4006 }
4007 VendVerdict::Refuse(why) => {
4008 crate::log_warn!("v2: refused a vended channel key for {}: {why}", p.channel_id);
4009 let _ = crate::db::community::drop_pending_channel_key(p.id);
4011 }
4012 VendVerdict::Park(_) => {}
4014 }
4015 }
4016 adopted
4017}
4018
4019pub async fn grant_channel_access<T: Transport + ?Sized>(
4029 transport: &T,
4030 community: &CommunityV2,
4031 channel_id: &ChannelId,
4032 member: &PublicKey,
4033) -> Result<(), String> {
4034 let session = SessionGuard::capture();
4035 let my_pk = me_pk()?;
4036 let ch = community.channel(channel_id).ok_or("unknown channel")?;
4037 if !ch.private {
4038 return Err("channel is public — every member already reads it".to_string());
4039 }
4040 if ch.key.is_none() {
4041 return Err("we hold no key for this channel, so we cannot vend it".to_string());
4042 }
4043 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4044 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4045 let owner_hex = community.owner()?.to_hex();
4046 let mut roster = fetch_authority(transport, community).await.roles;
4052 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4053 for r in cached.roles {
4054 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4055 roster.roles.push(r);
4056 }
4057 }
4058 for g in cached.grants {
4059 if !roster.grants.iter().any(|x| x.member == g.member) {
4060 roster.grants.push(g);
4061 }
4062 }
4063 if !session.is_valid() {
4064 return Err("account changed during grant".to_string());
4065 }
4066 if !roster.is_authorized_in(&my_pk.to_hex(), Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
4069 return Err("not authorized to manage this channel's access".to_string());
4070 }
4071 let role_id = roster
4076 .channel_roles(&chan_hex)
4077 .into_iter()
4078 .find(|r| r.permissions == crate::community::roles::Permissions::empty())
4079 .map(|r| r.role_id.clone())
4080 .ok_or("channel has no permission-less access role to grant")?;
4081
4082 let mut role_ids: Vec<String> = roster.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect();
4083 if !role_ids.contains(&role_id) {
4084 role_ids.push(role_id.clone());
4085 }
4086 grant_roles(transport, community, member, role_ids.clone()).await?;
4087 if !session.is_valid() {
4088 return Err("account changed during grant".to_string());
4089 }
4090 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids }));
4091 let bundle = bundle_of_with_overlay(
4093 community,
4094 BundleAudience::Member(*member),
4095 Some(my_pk),
4096 None,
4097 None,
4098 std::slice::from_ref(&role_id),
4099 &[],
4100 );
4101 let signer = crate::signer::active_signer()?;
4102 let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
4103 if !session.is_valid() {
4104 return Err("account changed before vending the key".to_string());
4105 }
4106 transport.publish(&wrap, &community.relays).await?;
4107 Ok(())
4108}
4109
4110pub async fn revoke_channel_access<T: Transport + ?Sized>(
4116 transport: &T,
4117 community: &CommunityV2,
4118 channel_id: &ChannelId,
4119 member: &PublicKey,
4120) -> Result<(), String> {
4121 let session = SessionGuard::capture();
4122 let my_pk = me_pk()?;
4123 let ch = community.channel(channel_id).ok_or("unknown channel")?;
4124 if !ch.private {
4125 return Err("channel is public — there is no access to revoke".to_string());
4126 }
4127 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4128 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4129 let owner_hex = community.owner()?.to_hex();
4130 let mut roster = fetch_authority(transport, community).await.roles;
4133 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4134 for r in cached.roles {
4135 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4136 roster.roles.push(r);
4137 }
4138 }
4139 for g in cached.grants {
4140 if !roster.grants.iter().any(|x| x.member == g.member) {
4141 roster.grants.push(g);
4142 }
4143 }
4144 if !session.is_valid() {
4145 return Err("account changed during revoke".to_string());
4146 }
4147 if !roster.is_authorized_in(&my_pk.to_hex(), Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
4148 return Err("not authorized to manage this channel's access".to_string());
4149 }
4150 if *member == community.owner()? {
4151 return Err("the owner is supreme and cannot be removed".to_string());
4152 }
4153 let access_ids = roster.channel_role_ids(&chan_hex);
4154 if access_ids.is_empty() {
4158 return Err("this channel's access role has not folded yet — retry once the control plane serves it".to_string());
4159 }
4160 let remaining: Vec<String> = roster
4161 .roles_of(&member.to_hex())
4162 .map(|r| r.role_id.clone())
4163 .filter(|id| !access_ids.contains(id))
4164 .collect();
4165 grant_roles(transport, community, member, remaining.clone()).await?;
4166 if !session.is_valid() {
4167 return Err("account changed during revoke".to_string());
4168 }
4169 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids: remaining }));
4170 rekey_channel_excluding(transport, community, channel_id, &roster, &access_ids, member).await
4174}
4175
4176async fn rekey_channel_excluding<T: Transport + ?Sized>(
4186 transport: &T,
4187 community: &CommunityV2,
4188 channel_id: &ChannelId,
4189 roster: &crate::community::roles::CommunityRoles,
4190 access_ids: &[String],
4191 removed: &PublicKey,
4192) -> Result<(), String> {
4193 let session = SessionGuard::capture();
4194 let lock = super::realtime::follow_lock(community.id());
4196 let _guard = lock.lock().await;
4197 let signer = crate::signer::active_signer()?;
4198 let my_pk = me_pk()?;
4199 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4200 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4201 let ch = community.channel(channel_id).ok_or("unknown channel")?.clone();
4202 let old_key = ch.key.ok_or("we hold no key for this channel, so we cannot rotate it")?;
4203 let new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
4204 let owner = community.owner()?;
4205 let owner_hex = owner.to_hex();
4206
4207 let removed_hex = removed.to_hex();
4211 let mut recipients: Vec<PublicKey> = vec![my_pk];
4212 if owner != my_pk {
4213 recipients.push(owner);
4214 }
4215 for g in &roster.grants {
4216 if g.member == removed_hex || g.member == owner_hex {
4217 continue;
4218 }
4219 if !g.role_ids.iter().any(|id| access_ids.contains(id)) {
4220 continue;
4221 }
4222 if let Ok(pk) = PublicKey::parse(&g.member) {
4223 if !recipients.contains(&pk) {
4224 recipients.push(pk);
4225 }
4226 }
4227 }
4228 let new_key = mint_or_reuse_rotation_key(&cid_hex, &chan_hex, new_epoch.0)?;
4231 let prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
4232 let mut blobs = Vec::with_capacity(recipients.len());
4233 for r in &recipients {
4234 blobs.push(
4235 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(*channel_id), new_epoch, &new_key)
4236 .await
4237 .map_err(|e| e.to_string())?,
4238 );
4239 }
4240 let group = channel_rekey_group_key(&community.community_root, channel_id, new_epoch);
4241 let at_secs = now_ms() / 1000;
4242 let chunks = rekey::build_rekey_chunks(&signer, my_pk, &group, RekeyScope::Channel(*channel_id), new_epoch, ch.epoch, &prev_commit, &blobs, at_secs, my_authority_citation(community, &my_pk).as_ref())
4243 .await
4244 .map_err(|e| e.to_string())?;
4245 if !session.is_valid() {
4246 return Err("account changed during channel rekey".to_string());
4247 }
4248 for c in &chunks {
4249 transport.publish_durable(c, &community.relays).await?;
4250 }
4251 if !session.is_valid() {
4252 return Err("account changed during channel rekey".to_string());
4253 }
4254 if crate::db::community::community_protocol(community.id())?.is_none() {
4255 return Err("community removed during channel rekey".to_string());
4256 }
4257 crate::db::community::advance_channel_epoch(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
4259 crate::db::community::store_epoch_key(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
4260 Ok(())
4261}
4262
4263pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
4266 let session = SessionGuard::capture();
4267 let lock = super::realtime::follow_lock(community.id());
4269 let _guard = lock.lock().await;
4270 let my_pk = me_pk()?;
4271 ensure_channel_manager(community, &my_pk)?;
4272 let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
4276 name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
4277 });
4278 meta.deleted = Some(true);
4279 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
4280 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
4281 if !session.is_valid() {
4282 return Err("account changed during channel delete".to_string());
4283 }
4284 let mut updated = community.clone();
4285 updated.channels.retain(|c| c.id.0 != channel_id.0);
4286 crate::db::community::save_community_v2(&updated)?;
4287 Ok(())
4288}
4289
4290pub async fn follow_control<T: Transport + ?Sized>(
4314 transport: &T,
4315 community: &CommunityV2,
4316 session: &SessionGuard,
4317) -> Result<Option<CommunityV2>, String> {
4318 community.owner()?; let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
4320 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4321
4322 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
4328 .into_iter()
4329 .filter(|(_, f)| f.0 == community.root_epoch.0)
4330 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
4331 .collect();
4332
4333 let mut editions: Vec<ParsedEdition> = Vec::new();
4337 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
4338 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
4339 let mut oldest: Option<u64> = None;
4340 let mut until: Option<u64> = None;
4341 let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false };
4342 let mut authority = AuthoritySet::owner_only();
4343 let mut truncated = true;
4349 for _ in 0..FOLLOW_MAX_PAGES {
4350 let query = Query {
4354 kinds: vec![stream::KIND_WRAP],
4355 authors: vec![control.pk_hex()],
4356 until,
4357 limit: Some(FOLLOW_PAGE),
4358 evidence: crate::community::transport::Evidence::Quorum,
4359 ..Default::default()
4360 };
4361 let wraps = transport.fetch(&query, &community.relays).await?;
4362 let mut fresh = 0usize;
4366 for w in &wraps {
4367 if !seen_wraps.insert(w.id) {
4368 continue;
4369 }
4370 fresh += 1;
4371 let at = w.created_at.as_secs();
4372 if oldest.is_none_or(|o| at < o) {
4373 oldest = Some(at);
4374 }
4375 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
4378 if seen.insert(ed.inner_id) {
4379 editions.push(ed);
4380 }
4381 }
4382 }
4383 authority = fold_authority(community, &editions, &floors);
4386 fold = apply_control_fold(community, &editions, &floors, &authority);
4387 if !(fold.gapped || authority.gapped) {
4388 truncated = false; break;
4390 }
4391 if fresh == 0 {
4392 truncated = wraps.len() >= FOLLOW_PAGE;
4397 break;
4398 }
4399 until = oldest;
4400 }
4401
4402 if !session.is_valid() {
4405 return Err("account changed during control follow".to_string());
4406 }
4407 if crate::db::community::community_protocol(community.id())?.is_none() {
4410 return Ok(None);
4411 }
4412 for h in fold.heads.iter().chain(authority.heads.iter()) {
4418 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)?;
4419 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)?;
4420 }
4421 let mut authority_changed = false;
4424 let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
4428 if let Some((banned, version)) = &authority.banlist_persist {
4429 let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4430 crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
4431 let mut after = banned.clone();
4432 before.sort();
4433 after.sort();
4434 authority_changed |= before != after;
4435 }
4436 let newest_roster_at: i64 = editions
4443 .iter()
4444 .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
4445 .map(|e| e.created_at as i64)
4446 .max()
4447 .unwrap_or(0);
4448 let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4454 let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
4455 let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
4456 && stored.grants.iter().all(|g| {
4457 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
4458 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
4459 !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
4460 })
4461 });
4462 if !truncated && !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
4467 authority_changed |= stored != authority.roles;
4468 crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
4469 }
4470 if !truncated && !authority.gapped && !fold.gapped {
4476 if let Ok(owner) = community.owner() {
4477 let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
4478 let live = flatten_link_sets(&sets);
4479 let mut before = crate::db::community::get_community_invite_registry(&cid_hex).unwrap_or_default();
4480 before.sort();
4481 if before != live {
4482 crate::db::community::set_community_invite_registry(&cid_hex, &live)?;
4483 authority_changed = true;
4484 }
4485 crate::db::community::replace_invite_link_sets(&cid_hex, &sets)?;
4488 }
4489 }
4490 if authority_changed {
4495 crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
4496 }
4497 match fold.updated {
4498 Some(u) => {
4499 crate::db::community::save_community_v2(&u)?;
4500 Ok(Some(u))
4501 }
4502 None => Ok(None),
4503 }
4504}
4505
4506const FOLLOW_MAX_PAGES: usize = 32;
4515const FOLLOW_PAGE: usize = 500;
4516const COMPACT_MAX_PAGES: usize = 512;
4521
4522#[derive(Clone)]
4524struct FoldedHead {
4525 entity_hex: String,
4526 version: u64,
4527 self_hash: [u8; 32],
4528 inner_id: [u8; 32],
4529}
4530
4531struct ControlFold {
4537 updated: Option<CommunityV2>,
4538 heads: Vec<FoldedHead>,
4539 gapped: bool,
4540}
4541
4542type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
4544
4545fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
4566 use crate::community::roles::Permissions;
4567 use std::collections::BTreeMap;
4568
4569 let owner_hex = community.owner().ok().map(|o| o.to_hex());
4570
4571 let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
4572 for e in editions {
4573 groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
4574 }
4575
4576 let mut out = community.clone();
4577 let mut changed = false;
4578 let mut heads = Vec::new();
4579 let mut gapped = false;
4580 for ((vsk_code, eid), group) in &groups {
4581 let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
4586 let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
4587 if !is_meta && !is_channel {
4588 continue;
4589 }
4590 let required = if is_meta { Permissions::MANAGE_METADATA } else { Permissions::MANAGE_CHANNELS };
4595 let authed: Vec<&ParsedEdition> = group
4596 .iter()
4597 .copied()
4598 .filter(|e| {
4599 let author = e.author.to_hex();
4600 !authority.banned.contains(&author)
4603 && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
4604 && citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
4608 })
4609 .collect();
4610 if authed.is_empty() {
4611 continue;
4612 }
4613 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
4614 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
4615 let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
4616 gapped |= entity_gapped;
4617 let Some(hi) = hi else { continue };
4618
4619 let head = authed[hi];
4620 heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
4621 if is_meta {
4622 if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
4623 changed |= apply_community_metadata(&mut out, meta);
4624 }
4625 } else if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
4626 changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
4630 }
4631 }
4632 ControlFold { updated: changed.then_some(out), heads, gapped }
4633}
4634
4635fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
4642 let floor_v = floor.map(|f| f.0).unwrap_or(0);
4643 if floor_v == 0 {
4644 return (version::bootstrap_head(fold_eds, 0), false);
4645 }
4646 let floor_hash = floor.map(|f| &f.1);
4647 let held_inner = floor.and_then(|f| f.2);
4648 let result = version::fold(fold_eds, floor_v, floor_hash);
4649 if result.anchored {
4650 return (result.head, result.gap); }
4652 if result.head.is_none() && !result.gap {
4653 return (None, false); }
4655 let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
4659 let win_hash = match fork {
4660 Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
4661 _ => return (None, true), };
4663 let re = version::fold(fold_eds, floor_v, Some(&win_hash));
4664 if !re.anchored {
4665 return (None, true);
4666 }
4667 (re.head, re.gap)
4668}
4669
4670struct AuthoritySet {
4675 roles: crate::community::roles::CommunityRoles,
4676 banned: std::collections::BTreeSet<String>,
4677 heads: Vec<FoldedHead>,
4678 gapped: bool,
4679 banlist_persist: Option<(Vec<String>, u64)>,
4683 banned_at: std::collections::BTreeMap<String, u64>,
4687}
4688
4689impl AuthoritySet {
4690 fn owner_only() -> Self {
4693 AuthoritySet {
4694 roles: Default::default(),
4695 banned: Default::default(),
4696 heads: vec![],
4697 gapped: false,
4698 banlist_persist: None,
4699 banned_at: Default::default(),
4700 }
4701 }
4702}
4703
4704fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
4711 use crate::community::roles::Permissions;
4712 use std::collections::BTreeMap;
4713
4714 let cid = community.id();
4715 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
4716 let owner = community.owner().ok();
4717 let owner_hex = owner.map(|o| o.to_hex());
4718 let banlist_eid = super::derive::banlist_locator(cid);
4719 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
4720
4721 let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
4722 for e in editions {
4723 if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
4724 groups.entry(e.entity_id).or_default().push(e);
4725 }
4726 }
4727
4728 let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
4736 let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
4737 let mut gapped = false;
4738
4739 for (eid, group) in &groups {
4740 if *eid == banlist_eid {
4742 continue;
4743 }
4744 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
4745 let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
4746 let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
4747 gapped |= entity_gapped;
4748 let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
4749
4750 for p in group {
4751 if p.version < floor_v {
4753 continue;
4754 }
4755 let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
4756 match p.vsk.as_str() {
4757 vsk::ROLE => {
4758 if let Some(role) = super::roles::parse_role_content(&p.content) {
4760 if role.role_id == entity_hex && role.position != 0 {
4761 role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
4762 }
4763 }
4764 }
4765 vsk::GRANT => {
4766 if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
4767 if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
4768 if super::derive::grant_locator(cid, &member) == *eid {
4769 grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
4770 grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
4771 }
4772 }
4773 }
4774 }
4775 _ => {}
4776 }
4777 }
4778 }
4779 for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
4780 cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
4781 }
4782
4783 let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4784 let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty);
4787
4788 let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4799 let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
4804 let banlist_authored: Vec<&ParsedEdition> = groups
4805 .get(&banlist_eid)
4806 .map(|g| {
4807 g.iter()
4808 .copied()
4809 .filter(|e| {
4810 let ah = e.author.to_hex();
4811 !banned_authors.contains(ah.as_str())
4812 && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
4813 && citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
4814 })
4815 .collect()
4816 })
4817 .unwrap_or_default();
4818 let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
4824 for p in &banlist_authored {
4825 for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
4826 if owner_hex.as_deref() == Some(t.as_str()) {
4827 continue;
4828 }
4829 let slot = banned_at.entry(t).or_insert(0);
4830 *slot = (*slot).max(p.created_at);
4831 }
4832 }
4833 let mut banlist_persist: Option<(Vec<String>, u64)> = None;
4834 let mut banlist_head: Option<FoldedHead> = None;
4835 let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
4836 persisted_banned.into_iter().collect()
4837 } else {
4838 let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
4839 let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
4840 gapped |= g;
4841 match hi {
4842 Some(hi) => {
4843 let head = banlist_authored[hi];
4844 let ah = head.author.to_hex();
4845 let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
4846 .unwrap_or_default()
4847 .into_iter()
4848 .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
4849 .take(super::roles::MAX_BANLIST)
4850 .collect();
4851 banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
4852 banlist_persist = Some((list.clone(), head.version));
4853 list.into_iter().collect()
4854 }
4855 None => persisted_banned.into_iter().collect(),
4856 }
4857 };
4858
4859 let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned);
4863 if let Some(bh) = banlist_head {
4864 heads.push(bh);
4865 }
4866
4867 if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
4872 authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
4873 authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
4874 let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
4875 authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
4876 authorized.grants.retain(|g| !g.role_ids.is_empty());
4877 }
4878
4879 AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
4880}
4881
4882struct AuthorityCand {
4885 role: Option<crate::community::roles::Role>,
4886 grant: Option<crate::community::roles::MemberGrant>,
4887 author: PublicKey,
4888 head: FoldedHead,
4889 citation: Option<crate::community::edition::AuthorityCitation>,
4892}
4893
4894fn citation_ok_in_fold(
4903 cid: &crate::community::CommunityId,
4904 heads: &[FoldedHead],
4905 owner_hex: Option<&str>,
4906 author: &PublicKey,
4907 citation: Option<&crate::community::edition::AuthorityCitation>,
4908) -> bool {
4909 let actor_hex = author.to_hex();
4910 if owner_hex == Some(actor_hex.as_str()) {
4911 return true;
4912 }
4913 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
4914 let as_entity: Vec<crate::community::roster::EntityHead> = heads
4915 .iter()
4916 .map(|h| crate::community::roster::EntityHead {
4917 entity_hex: h.entity_hex.clone(),
4918 version: h.version,
4919 self_hash: h.self_hash,
4920 inner_id: h.inner_id,
4921 citation: None,
4922 })
4923 .collect();
4924 crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
4925}
4926
4927fn select_authorized(
4936 cid: &crate::community::CommunityId,
4937 role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
4938 grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
4939 owner_hex: Option<&str>,
4940 excluded: &std::collections::BTreeSet<String>,
4941) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
4942 use crate::community::roles::{CommunityRoles, Permissions};
4943 let mut accepted = CommunityRoles::default();
4944 let mut heads: Vec<FoldedHead> = Vec::new();
4945 let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
4950 for _ in 0..bound {
4951 let mut next = CommunityRoles::default();
4952 let mut next_heads: Vec<FoldedHead> = Vec::new();
4953
4954 for cands in role_cands.values() {
4955 let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
4968 let mut standing: Option<u32> = None;
4969 let mut i = cands.len();
4970 while i > 0 {
4971 let hi = i;
4972 let ver = cands[i - 1].head.version;
4973 while i > 0 && cands[i - 1].head.version == ver {
4974 i -= 1;
4975 }
4976 for c in cands[i..hi].iter().rev() {
4978 let Some(role) = &c.role else { continue };
4979 let ah = c.author.to_hex();
4980 if excluded.contains(&ah) || role.position == 0 {
4981 continue;
4982 }
4983 if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
4984 continue;
4985 }
4986 if let Some(prev) = standing {
4987 if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
4988 continue;
4989 }
4990 }
4991 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
4992 continue;
4993 }
4994 admissible.insert(c.head.self_hash);
4995 standing = Some(role.position);
4996 break;
4997 }
4998 }
4999 for c in cands {
5000 let Some(role) = &c.role else { continue };
5001 if !admissible.contains(&c.head.self_hash) {
5002 continue;
5003 }
5004 next.roles.push(role.clone());
5005 next_heads.push(c.head.clone());
5006 break; }
5008 }
5009 for cands in grant_cands.values() {
5010 for c in cands {
5011 let Some(grant) = &c.grant else { continue };
5012 let ah = c.author.to_hex();
5013 if excluded.contains(&ah) || excluded.contains(&grant.member) {
5014 continue;
5015 }
5016 let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
5019 let Some(positions) = positions else { continue };
5020 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
5021 continue;
5022 }
5023 if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
5024 && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
5025 {
5026 next_heads.push(c.head.clone());
5030 if !grant.role_ids.is_empty() {
5031 next.grants.push(grant.clone());
5032 }
5033 break;
5034 }
5035 }
5036 }
5037
5038 let converged = next.roles == accepted.roles && next.grants == accepted.grants;
5039 accepted = next;
5040 heads = next_heads;
5041 if converged {
5042 break;
5043 }
5044 }
5045 (accepted, heads)
5046}
5047
5048fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
5052 let mut changed = false;
5053 if out.name != meta.name {
5054 out.name = meta.name;
5055 changed = true;
5056 }
5057 if out.description != meta.description {
5058 out.description = meta.description;
5059 changed = true;
5060 }
5061 if out.icon != meta.icon {
5065 out.icon = meta.icon;
5066 changed = true;
5067 }
5068 if out.banner != meta.banner {
5069 out.banner = meta.banner;
5070 changed = true;
5071 }
5072 if out.meta_custom != meta.custom {
5075 out.meta_custom = meta.custom;
5076 changed = true;
5077 }
5078 if out.meta_extra != meta.extra {
5079 out.meta_extra = meta.extra;
5080 changed = true;
5081 }
5082 let relays = crate::community::cap_relays(meta.relays);
5090 if !relays.is_empty() && out.relays != relays {
5091 out.relays = relays;
5092 changed = true;
5093 }
5094 changed
5095}
5096
5097fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
5102 let deleted = meta.deleted.unwrap_or(false);
5103 if deleted {
5104 let before = out.channels.len();
5105 out.channels.retain(|c| c.id.0 != id.0);
5106 return out.channels.len() != before;
5107 }
5108 match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
5109 Some(existing) => {
5110 let mut changed = false;
5111 if existing.name != meta.name {
5112 existing.name = meta.name;
5113 changed = true;
5114 }
5115 if existing.voice != meta.voice {
5118 existing.voice = meta.voice;
5119 changed = true;
5120 }
5121 if existing.meta_custom != meta.custom {
5122 existing.meta_custom = meta.custom;
5123 changed = true;
5124 }
5125 if existing.meta_extra != meta.extra {
5126 existing.meta_extra = meta.extra;
5127 changed = true;
5128 }
5129 if !meta.private && (existing.private || existing.key.is_some()) {
5138 existing.private = false;
5139 existing.key = None;
5140 changed = true;
5141 }
5142 changed
5143 }
5144 None if !meta.private => {
5145 out.channels.push(ChannelV2 {
5148 id,
5149 name: meta.name,
5150 private: false,
5151 key: None,
5152 epoch: out.root_epoch,
5153 voice: meta.voice,
5154 meta_custom: meta.custom,
5155 meta_extra: meta.extra,
5156 });
5157 true
5158 }
5159 None => {
5160 out.channels.push(ChannelV2 {
5166 id,
5167 name: meta.name,
5168 private: true,
5169 key: None,
5170 epoch: Epoch(0),
5171 voice: meta.voice,
5172 meta_custom: meta.custom,
5173 meta_extra: meta.extra,
5174 });
5175 true
5176 }
5177 }
5178}
5179
5180pub struct RekeyFollow {
5184 pub updated: Option<CommunityV2>,
5187 pub self_removed: bool,
5190 pub dissolved: bool,
5193}
5194
5195const MAX_ADDRESSING_ROOTS: usize = 8;
5202
5203pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
5212 let mut roots: Vec<[u8; 32]> = vec![cur_root];
5213 let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
5214 .unwrap_or_default();
5215 archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
5216 for (_, r) in archived {
5217 if !roots.contains(&r) {
5218 roots.push(r);
5219 }
5220 }
5221 roots.truncate(MAX_ADDRESSING_ROOTS);
5222 roots
5223}
5224
5225#[cfg(debug_assertions)]
5260pub async fn debug_explain_base_rekey<T: Transport + ?Sized>(
5261 transport: &T,
5262 community: &CommunityV2,
5263) -> Result<serde_json::Value, String> {
5264 let my_xonly = me_pk()?.to_bytes();
5265 let owner = community.owner()?;
5266 let owner_hex = owner.to_hex();
5267 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5268 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5269 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5270 let held_epoch = community.root_epoch;
5271 let held_key = community.community_root;
5272 let next = Epoch(held_epoch.0.saturating_add(1));
5273 let group = base_rekey_group_key(&held_key, community.id(), next);
5274 let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
5275 let rotations = rekey::collect_rotations(&chunks);
5276
5277 let reports: Vec<serde_json::Value> = rotations
5278 .iter()
5279 .map(|r| {
5280 let rotator_is_owner = r.rotator == owner;
5281 let rotator_authorized = rotator_is_owner
5284 || (!banned.contains(&r.rotator.to_hex())
5285 && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
5286 let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
5287 let epoch_ok = r.new_epoch.0 == next.0;
5288 let complete = r.is_complete();
5289 let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
5290 let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
5291 let owner_kept = r.rotator == owner
5296 || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
5297 let verdict = if !rotator_authorized {
5299 "REJECTED: rotator holds no BAN authority in the folded roster"
5300 } else if !scope_ok {
5301 "REJECTED: scope is not Root"
5302 } else if !epoch_ok {
5303 "REJECTED: new_epoch != held+1"
5304 } else if !complete {
5305 "WAIT: rotation incomplete (missing chunk) — never concludes removal"
5306 } else if continuity != "Extends" {
5307 "REJECTED: continuity does not extend my held root (FORK/GAP)"
5308 } else if has_my_blob {
5309 "ADOPT: authorized + complete + continuous + my blob present"
5310 } else {
5311 "REMOVED: complete authorized rotation with no blob for me"
5312 };
5313 serde_json::json!({
5314 "rotator": r.rotator.to_hex(),
5315 "rotator_is_recorded_owner": rotator_is_owner,
5316 "rotator_authorized_ban": rotator_authorized,
5317 "scope_is_root": scope_ok,
5318 "new_epoch": r.new_epoch.0,
5319 "prev_epoch": r.prev_epoch.0,
5320 "declared_chunks": r.declared_chunks,
5321 "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
5322 "is_complete": complete,
5323 "continuity_vs_held_root": continuity,
5324 "my_blob_present": has_my_blob,
5325 "owner_kept": owner_kept,
5326 "blob_count": r.blobs.len(),
5327 "verdict": verdict,
5328 })
5329 })
5330 .collect();
5331
5332 Ok(serde_json::json!({
5333 "recorded_owner": owner.to_hex(),
5334 "held_root_epoch": held_epoch.0,
5335 "probing_next_epoch": next.0,
5336 "base_plane_pk": group.pk_hex(),
5337 "raw_chunks_parsed": chunks.len(),
5338 "rotations_found": rotations.len(),
5339 "rotations": reports,
5340 }))
5341}
5342
5343pub async fn follow_rekeys<T: Transport + ?Sized>(
5344 transport: &T,
5345 community: &CommunityV2,
5346 session: &SessionGuard,
5347) -> Result<RekeyFollow, String> {
5348 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5351 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
5352 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
5353 }
5354 if is_dissolved(transport, community).await {
5360 if session.is_valid() {
5361 let _ = crate::db::community::set_community_dissolved(&cid_hex);
5362 }
5363 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
5364 }
5365 let signer = crate::signer::active_signer()?;
5366 let my_pk = me_pk()?;
5367 let my_xonly = my_pk.to_bytes();
5368 let owner = community.owner()?;
5369 let owner_hex = owner.to_hex();
5370 let mut cur = community.clone();
5371 let mut changed = false;
5372
5373 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5385 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5386 let me_hex = my_pk.to_hex();
5387 let cited_ok = |rot: &rekey::Rotation| -> bool {
5396 citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
5397 };
5398 let channel_rotator_ok = |rotator: &PublicKey| -> bool {
5399 if *rotator == owner {
5400 return true;
5401 }
5402 let rh = rotator.to_hex();
5403 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
5404 };
5405 let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
5410 if *rotator == owner {
5411 return true;
5412 }
5413 let rh = rotator.to_hex();
5414 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
5415 };
5416 let base_rotator_ok = |rotator: &PublicKey| -> bool {
5422 if *rotator == owner {
5423 return true;
5424 }
5425 let rh = rotator.to_hex();
5426 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
5427 };
5428 let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
5434 if *rotator == owner {
5435 return true;
5436 }
5437 let rh = rotator.to_hex();
5438 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
5439 };
5440
5441 const MAX_STEPS: usize = 128;
5445 for _ in 0..MAX_STEPS {
5446 let mut advanced = false;
5447
5448 let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
5453
5454 let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
5457 for cid in channel_ids {
5458 let (held_key, held_epoch) = match cur.channel(&cid) {
5459 Some(ch) => (ch.key, ch.epoch),
5460 None => continue,
5461 };
5462 let next = Epoch(held_epoch.0.saturating_add(1));
5463 let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
5464 let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
5465 for (ri, root) in addressing_roots.iter().enumerate() {
5468 let group = channel_rekey_group_key(root, &cid, next);
5469 let chunks = match fetch_rekey_chunks(transport, &cur.relays, &group).await {
5470 Ok(c) => c,
5471 Err(e) => {
5472 crate::log_warn!(
5473 "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
5474 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
5475 );
5476 return Err(e);
5477 }
5478 };
5479 if chunks.is_empty() {
5480 continue;
5481 }
5482 crate::log_debug!(
5483 "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
5484 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
5485 );
5486 batches.push((chunks, held_key.map(|k| (held_epoch, k))));
5487 }
5488 match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
5495 Advance::Adopt { new_key } => {
5496 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
5497 ch.key = Some(new_key);
5498 ch.epoch = next;
5499 }
5500 crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
5501 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) {
5506 crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
5507 }
5508 advanced = true;
5509 changed = true;
5510 }
5511 Advance::Removed => {
5512 match held_key {
5513 Some(_) => {
5515 cur.channels.retain(|c| c.id.0 != cid.0);
5516 }
5517 None => {
5522 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
5523 ch.epoch = next;
5524 }
5525 }
5526 }
5527 advanced = true;
5528 changed = true;
5529 }
5530 Advance::Stay => {}
5531 }
5532 }
5533
5534 {
5538 let held_epoch = cur.root_epoch;
5539 let held_key = cur.community_root;
5540 let next = Epoch(held_epoch.0.saturating_add(1));
5541 let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
5542 let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
5543 let batches = vec![(chunks, Some((held_epoch, held_key)))];
5544 let base_admissible = |r: &rekey::Rotation| -> bool {
5551 if r.rotator == owner {
5552 return true; }
5554 if !cited_ok(r) {
5558 return false;
5559 }
5560 let rotator_hex = r.rotator.to_hex();
5561 let has_blob = |xonly: &[u8; 32]| {
5562 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
5563 };
5564 if !has_blob(&owner.to_bytes()) {
5566 return false;
5567 }
5568 for g in &roster.grants {
5569 if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
5570 continue; }
5572 if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
5574 if let Ok(pk) = PublicKey::from_hex(&g.member) {
5575 if !has_blob(&pk.to_bytes()) {
5576 return false; }
5578 }
5579 }
5580 }
5581 true
5582 };
5583 match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
5584 Advance::Adopt { new_key } => {
5585 cur.community_root = new_key;
5586 cur.root_epoch = next;
5587 if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
5591 crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
5592 }
5593 advanced = true;
5594 changed = true;
5595 }
5596 Advance::Removed => {
5597 if !session.is_valid() {
5598 return Err("account changed during rekey follow".to_string());
5599 }
5600 return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
5601 }
5602 Advance::Stay => {}
5603 }
5604 }
5605
5606 if !advanced {
5607 break;
5608 }
5609 }
5610
5611 if !changed {
5612 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
5613 }
5614 if !session.is_valid() {
5615 return Err("account changed during rekey follow".to_string());
5616 }
5617 if crate::db::community::community_protocol(community.id())?.is_none() {
5620 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
5621 }
5622 crate::db::community::save_community_v2(&cur)?;
5623 let _ = refresh_public_links(transport, &cur).await;
5631 Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
5632}
5633
5634enum Advance {
5637 Adopt { new_key: [u8; 32] },
5639 Removed,
5641 Stay,
5643}
5644
5645async fn fetch_rekey_chunks<T: Transport + ?Sized>(
5647 transport: &T,
5648 relays: &[String],
5649 group: &GroupKey,
5650) -> Result<Vec<rekey::RekeyChunk>, String> {
5651 const REKEY_PAGE: usize = 200;
5659 const REKEY_MAX_PAGES: usize = 6;
5660 let mut out = Vec::new();
5661 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
5662 let mut until: Option<u64> = None;
5663 let mut oldest: Option<u64> = None;
5664 for _ in 0..REKEY_MAX_PAGES {
5665 let query = Query {
5666 kinds: vec![stream::KIND_WRAP],
5667 authors: vec![group.pk_hex()],
5668 until,
5669 limit: Some(REKEY_PAGE),
5670 ..Default::default()
5671 };
5672 let wraps = transport.fetch_plane(group.keys(), &query, relays).await?;
5677 let mut fresh = 0usize;
5678 for w in &wraps {
5679 if !seen.insert(w.id) {
5680 continue;
5681 }
5682 fresh += 1;
5683 let at = w.created_at.as_secs();
5684 if oldest.is_none_or(|o| at < o) {
5685 oldest = Some(at);
5686 }
5687 if let Ok(opened) = stream::open_wrap(w, group) {
5688 if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
5689 out.push(chunk);
5690 }
5691 }
5692 }
5693 if fresh == 0 || wraps.len() < REKEY_PAGE {
5696 break;
5697 }
5698 match oldest {
5699 Some(o) if o > 0 => until = Some(o),
5700 _ => break,
5701 }
5702 }
5703 Ok(out)
5704}
5705
5706async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
5720 batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
5721 scope: RekeyScope,
5722 rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
5723 rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
5724 admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
5725 signer: &S,
5726 my_xonly: &[u8; 32],
5727 next_epoch: Epoch,
5728) -> Advance {
5729 let mut winners: Vec<[u8; 32]> = Vec::new();
5730 let mut saw_complete_candidate = false;
5731 let mut saw_outranking_candidate = false;
5732 let keyed = batches.iter().any(|(_, held)| held.is_some());
5733 for (chunks, held) in batches {
5734 let rotations = rekey::collect_rotations(chunks);
5735 for r in &rotations {
5736 if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
5737 continue;
5738 }
5739 if let Some((held_epoch, held_key)) = held {
5740 if r.continuity(*held_epoch, held_key) != Continuity::Extends {
5741 continue;
5742 }
5743 }
5744 if !admissible(r) {
5750 continue;
5751 }
5752 saw_complete_candidate = true;
5753 saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
5754 if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
5755 if let Ok(k) = rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob).await {
5756 winners.push(k);
5757 }
5758 }
5759 }
5760 }
5761 if !winners.is_empty() {
5762 let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
5770 return Advance::Adopt { new_key: winners[idx] };
5771 }
5772 if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
5773 Advance::Removed
5774 } else {
5775 Advance::Stay
5776 }
5777}
5778
5779#[cfg(test)]
5780mod tests {
5781 use crate::ClientRelayExt;
5782 use nostr_sdk::prelude::FinalizeEvent;
5783 use super::super::super::transport::memory::MemoryRelay;
5784 use super::*;
5785 use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
5786
5787 fn account_name(n: u32) -> String {
5789 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
5790 let mut acct = String::from("npub1");
5791 let mut v = n as usize;
5792 for _ in 0..58 {
5793 acct.push(B[v % 32] as char);
5794 v = v / 32 + 7;
5795 }
5796 acct
5797 }
5798
5799 struct Actor {
5801 keys: Keys,
5802 account: String,
5803 }
5804
5805 struct TestBed {
5811 _tmp: tempfile::TempDir,
5812 _guard: std::sync::MutexGuard<'static, ()>,
5813 relay: MemoryRelay,
5814 relays: Vec<String>,
5815 }
5816
5817 impl TestBed {
5818 fn new() -> (TestBed, Actor, Actor) {
5819 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
5820 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5821 crate::db::close_database();
5822 crate::db::clear_id_caches();
5823 let tmp = tempfile::tempdir().unwrap();
5824 crate::db::set_app_data_dir(tmp.path().to_path_buf());
5825
5826 let mk = || {
5827 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5828 let account = account_name(n);
5829 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
5830 crate::db::set_current_account(account.clone()).unwrap();
5831 crate::db::init_database(&account).unwrap();
5832 Actor { keys: Keys::generate(), account }
5833 };
5834 let owner = mk();
5835 let member = mk();
5836 let _ = crate::state::take_nostr_client();
5837 let bed = TestBed {
5838 _tmp: tmp,
5839 _guard: guard,
5840 relay: MemoryRelay::new(),
5841 relays: vec!["wss://r".to_string()],
5842 };
5843 (bed, owner, member)
5844 }
5845
5846 fn swap_to(&self, actor: &Actor) {
5851 crate::state::bump_session_generation();
5852 crate::db::set_current_account(actor.account.clone()).unwrap();
5853 crate::db::init_database(&actor.account).unwrap();
5854 crate::db::clear_id_caches();
5855 crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
5856 crate::state::set_my_public_key(actor.keys.public_key());
5857 }
5858 }
5859
5860 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
5862 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5863 crate::db::close_database();
5864 crate::db::clear_id_caches();
5865 let tmp = tempfile::tempdir().unwrap();
5866 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
5867 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5868 let acct = account_name(n);
5869 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
5870 crate::db::set_app_data_dir(tmp.path().to_path_buf());
5871 crate::db::set_current_account(acct.clone()).unwrap();
5872 crate::db::init_database(&acct).unwrap();
5873 let _ = crate::state::take_nostr_client();
5874 let owner = Keys::generate();
5875 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
5876 crate::state::set_my_public_key(owner.public_key());
5877 (tmp, guard, owner)
5878 }
5879
5880 struct SwapMidFetch {
5883 inner: MemoryRelay,
5884 }
5885 #[async_trait::async_trait]
5886 impl Transport for SwapMidFetch {
5887 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5888 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
5889 self.inner.publish(e, r).await
5890 }
5891 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5892 self.inner.publish_durable(e, r).await
5893 }
5894 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
5895 let out = self.inner.fetch(q, r).await;
5896 crate::state::bump_session_generation();
5897 out
5898 }
5899 }
5900
5901 struct SwapMidPublish {
5904 inner: MemoryRelay,
5905 }
5906 #[async_trait::async_trait]
5907 impl Transport for SwapMidPublish {
5908 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5909 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
5910 self.inner.publish(e, r).await
5911 }
5912 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5913 let out = self.inner.publish_durable(e, r).await;
5914 crate::state::bump_session_generation();
5915 out
5916 }
5917 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
5918 self.inner.fetch(q, r).await
5919 }
5920 }
5921
5922 struct FixedFetch {
5927 events: Vec<Event>,
5928 }
5929 #[async_trait::async_trait]
5930 impl Transport for FixedFetch {
5931 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5932 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
5933 Ok(())
5934 }
5935 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
5936 Ok(())
5937 }
5938 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
5939 Ok(self.events.clone())
5940 }
5941 }
5942
5943 async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
5946 let q = Query {
5947 kinds: vec![stream::KIND_WRAP],
5948 p_tags: vec![me.to_hex()],
5949 k_tags: vec!["3313".to_string()],
5950 ..Default::default()
5951 };
5952 relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
5953 }
5954
5955 #[tokio::test]
5956 async fn create_persists_and_reloads_a_v2_community() {
5957 let (_tmp, _guard, owner) = init_test_db();
5958 let relay = MemoryRelay::new();
5959 let relays = vec!["wss://r".to_string()];
5960
5961 let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
5962 assert!(created.identity.verify());
5963 assert_eq!(created.owner().unwrap(), owner.public_key());
5964 assert_eq!(created.channels.len(), 1);
5965
5966 assert_eq!(
5968 crate::db::community::community_protocol(created.id()).unwrap(),
5969 Some(crate::community::ConcordProtocol::V2)
5970 );
5971 let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
5972 assert_eq!(loaded.name, "Vectorville");
5973 assert_eq!(loaded.community_root, created.community_root);
5974 assert_eq!(loaded.identity, created.identity);
5975 assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
5976 assert!(!loaded.channels[0].private);
5977
5978 assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
5980 }
5981
5982 #[tokio::test]
5983 async fn owner_sends_and_reads_back_a_message() {
5984 let (_tmp, _guard, _owner) = init_test_db();
5985 let relay = MemoryRelay::new();
5986 let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
5987 let general = community.channels[0].id;
5988
5989 let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
5990 let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
5991 assert_ne!(id1, id2);
5992
5993 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
5994 let texts: Vec<String> = page
5995 .iter()
5996 .filter_map(|f| match &f.event {
5997 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
5998 _ => None,
5999 })
6000 .collect();
6001 assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
6002 }
6003
6004 #[tokio::test]
6005 async fn a_second_member_reads_the_public_channel_from_the_root() {
6006 let (_tmp, _guard, _owner) = init_test_db();
6010 let relay = MemoryRelay::new();
6011 let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
6012 let general = community.channels[0].id;
6013 send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
6014
6015 let member_view = community.clone();
6017 let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
6018 assert_eq!(page.len(), 1);
6019 assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
6020 assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
6021 }
6022
6023 async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
6026 fetch_channel(relay, community, channel, 100)
6027 .await
6028 .unwrap()
6029 .iter()
6030 .filter_map(|f| match &f.event {
6031 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
6032 _ => None,
6033 })
6034 .collect()
6035 }
6036
6037 #[tokio::test]
6038 async fn direct_invite_full_loop_owner_and_member_converse() {
6039 let (bed, owner, member) = TestBed::new();
6040
6041 bed.swap_to(&owner);
6043 let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
6044 let general = community.channels[0].id;
6045 send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
6046 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6047
6048 bed.swap_to(&member);
6050 assert!(
6051 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6052 "the member does not hold the community before joining"
6053 );
6054 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6055 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6056 assert_eq!(joined.id().0, community.id().0, "joined the same community");
6057 assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
6058 assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
6059
6060 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
6062 send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
6063
6064 bed.swap_to(&owner);
6066 assert_eq!(
6067 texts_in(&bed.relay, &community, &general).await,
6068 vec!["owner: welcome!", "member: thanks for the invite"],
6069 "both actors' messages interleave in ms order on the shared channel"
6070 );
6071
6072 let members = memberlist(&bed.relay, &community).await.unwrap();
6074 assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
6075 assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
6076 assert_eq!(members.len(), 2);
6077 }
6078
6079 #[tokio::test]
6083 async fn a_banned_member_is_refused_at_join_time() {
6084 let (bed, owner, member) = TestBed::new();
6085
6086 bed.swap_to(&owner);
6087 let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
6088 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6089 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
6090
6091 bed.swap_to(&member);
6092 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6093 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6094 assert!(err.contains("banned"), "refusal names the reason: {err}");
6095 assert!(
6096 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6097 "a refused join persists nothing"
6098 );
6099
6100 bed.swap_to(&owner);
6103 set_banlist(&bed.relay, &community, &[]).await.unwrap();
6104 bed.swap_to(&member);
6105 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6106 assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
6107 }
6108
6109 #[tokio::test]
6113 async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
6114 use crate::community::migration;
6115 let (bed, owner, member) = TestBed::new();
6116
6117 bed.swap_to(&owner);
6119 let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
6120 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
6121 let jm = join_material(&v2);
6122
6123 bed.swap_to(&member);
6126 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6127 let v1_cid = v1.id.to_hex();
6128 v1.owner_attestation = Some({
6129 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6130 .finalize(&owner.keys).unwrap().as_json()
6131 });
6132 crate::db::community::save_community(&v1).unwrap();
6133 let v1_channel = v1.channels[0].id.to_hex();
6134
6135 let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
6137 let signpost = migration::MigrationSignpost {
6138 v2_community_id: v2_hex.clone(),
6139 owner_xonly: owner.keys.public_key().to_hex(),
6140 owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
6141 relays: bed.relays.clone(),
6142 name: "Guild".into(),
6143 primary_channel: v1_channel.clone(),
6144 root_epoch: 0,
6145 };
6146 let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
6147 crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
6148
6149 let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
6151 assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
6152
6153 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
6155 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
6156 assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
6157 let _ = v1_channel;
6158
6159 assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
6161 }
6162
6163 #[tokio::test]
6168 async fn owner_wizard_then_member_migrate_and_stitch() {
6169 use crate::community::migration;
6170 let (bed, owner, member) = TestBed::new();
6171 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6172
6173 bed.swap_to(&owner);
6175 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6176 let v1_cid = v1.id.to_hex();
6177 let v1_channel = v1.channels[0].id.to_hex();
6178 v1.owner_attestation = Some({
6179 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6180 .finalize(&owner.keys).unwrap().as_json()
6181 });
6182 crate::db::community::save_community(&v1).unwrap();
6183
6184 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6186 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
6187 "owner's own client flipped to v2");
6188 assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
6191 "owner channel stitched to v2");
6192
6193 bed.swap_to(&member);
6195 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6196 m_v1.id = v1.id;
6198 m_v1.server_root_key = v1.server_root_key.clone();
6199 m_v1.channels[0].id = v1.channels[0].id;
6200 m_v1.owner_attestation = v1.owner_attestation.clone();
6201 crate::db::community::save_community(&m_v1).unwrap();
6202
6203 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
6206 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
6207 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
6208 "the FOLD ITSELF flipped the member (auto-drive)");
6209 assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
6210 "member holds the v2 twin");
6211 assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
6213 }
6214
6215 #[tokio::test]
6220 async fn wizard_publishes_the_twin_to_the_cross_device_list() {
6221 use crate::community::migration;
6222 let (bed, owner, _member) = TestBed::new();
6223 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6224
6225 bed.swap_to(&owner);
6226 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6227 let v1_cid = v1.id.to_hex();
6228 v1.owner_attestation = Some({
6229 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6230 .finalize(&owner.keys).unwrap().as_json()
6231 });
6232 crate::db::community::save_community(&v1).unwrap();
6233
6234 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6235
6236 let list = fetch_community_list(&bed.relay, &bed.relays).await.unwrap()
6238 .expect("the wizard published a community list");
6239 assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
6240 assert!(
6244 !list.tombstones.iter().any(|t| t.community_id == v1_cid),
6245 "migration must not tombstone the v1 community"
6246 );
6247 }
6248
6249 #[tokio::test]
6254 async fn wizard_refuses_while_a_drive_holds_the_claim() {
6255 use crate::community::migration;
6256 let (bed, owner, _member) = TestBed::new();
6257 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6258
6259 bed.swap_to(&owner);
6260 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6261 let v1_cid = v1.id.to_hex();
6262 v1.owner_attestation = Some({
6263 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6264 .finalize(&owner.keys).unwrap().as_json()
6265 });
6266 crate::db::community::save_community(&v1).unwrap();
6267
6268 migration::test_hold_drive_claim(&v1_cid);
6270 let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
6271 assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
6272 assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
6274 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
6275
6276 migration::test_release_drive_claim(&v1_cid);
6278 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6279 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
6280 }
6281
6282 #[tokio::test]
6288 async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
6289 use crate::community::migration;
6290 let (bed, owner, _member) = TestBed::new();
6291 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6292 let relay = std::sync::Arc::new(MemoryRelay::new());
6294
6295 bed.swap_to(&owner);
6296 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6297 let v1_cid = v1.id.to_hex();
6298 let v1_channel = v1.channels[0].id.to_hex();
6299 v1.owner_attestation = Some({
6300 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6301 .finalize(&owner.keys).unwrap().as_json()
6302 });
6303 crate::db::community::save_community(&v1).unwrap();
6304
6305 let twin = create_migration_twin(
6308 &*relay, "Guild", bed.relays.clone(), None,
6309 (v1.channels[0].id, "general".to_string()),
6310 ).await.unwrap();
6311 let v2_id = twin.identity.community_id;
6312 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
6313 crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
6314
6315 let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
6317
6318 let wizard = tokio::spawn({
6319 let relay = relay.clone();
6320 let v1 = v1.clone();
6321 async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
6322 });
6323
6324 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
6326 assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
6327 assert!(
6328 crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
6329 "the fence must not be stamped while the follow lock is held"
6330 );
6331
6332 drop(held);
6334 let flipped = wizard.await.unwrap().unwrap();
6335 assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
6336 assert_eq!(
6337 crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
6338 Some(v2_hex.as_str()),
6339 "the channel row is stitched to the twin, not pruned"
6340 );
6341 }
6342
6343 #[tokio::test]
6348 async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
6349 use crate::community::migration;
6350 let (bed, owner, banned) = TestBed::new();
6351 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6352
6353 bed.swap_to(&owner);
6355 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6356 let v1_cid = v1.id.to_hex();
6357 v1.owner_attestation = Some({
6358 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6359 .finalize(&owner.keys).unwrap().as_json()
6360 });
6361 crate::db::community::save_community(&v1).unwrap();
6362 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
6363
6364 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6365
6366 bed.swap_to(&banned);
6368 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6369 m_v1.id = v1.id;
6370 m_v1.server_root_key = v1.server_root_key.clone();
6371 m_v1.channels[0].id = v1.channels[0].id;
6372 m_v1.owner_attestation = v1.owner_attestation.clone();
6373 crate::db::community::save_community(&m_v1).unwrap();
6374 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
6375
6376 let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
6378 let payload = migration::parse_migration_payload(&raw).unwrap();
6379 assert!(payload.m.is_some());
6380 let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
6381 assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
6382 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
6383 assert!(
6384 crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
6385 "banned member never acquires the v2 twin"
6386 );
6387 }
6388
6389 #[tokio::test]
6394 async fn wizard_resume_continues_on_the_same_twin() {
6395 use crate::community::migration;
6396 let (bed, owner, banned) = TestBed::new();
6397 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6398
6399 bed.swap_to(&owner);
6400 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6401 let mut sibling = v1.channels[0].clone();
6403 sibling.id = crate::community::ChannelId(crate::community::random_32());
6404 sibling.name = "offtopic".into();
6405 v1.channels.push(sibling.clone());
6406 let v1_cid = v1.id.to_hex();
6407 v1.owner_attestation = Some({
6408 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6409 .finalize(&owner.keys).unwrap().as_json()
6410 });
6411 crate::db::community::save_community(&v1).unwrap();
6412 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
6413
6414 let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
6417 let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
6418 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
6419
6420 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6422 assert_eq!(v2_hex, minted_hex, "no second twin was minted");
6423 let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
6424 assert_eq!(ledger_v2, minted_hex);
6425 assert_eq!(phase, migration::PHASE_FLIPPED);
6426 assert_eq!(
6429 crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
6430 Some(minted_hex.as_str()),
6431 "sibling channel re-parented by the resumed run"
6432 );
6433 let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
6434 let (_, _, wire_banlist) = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
6435 .await
6436 .map(|(c, h, b)| (c, h, b))
6437 .unwrap();
6438 assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
6439 "the resumed banlist clone is folded from the twin's wire control plane");
6440
6441 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
6444 let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6445 assert_eq!(healed, minted_hex);
6446 let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
6447 assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
6448
6449 let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
6455 let v1b_cid = v1b.id.to_hex();
6456 v1b.owner_attestation = Some({
6457 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
6458 .finalize(&owner.keys).unwrap().as_json()
6459 });
6460 crate::db::community::save_community(&v1b).unwrap();
6461 let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
6462 let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
6463 crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
6464 crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
6466 assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
6467 assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
6468 }
6469
6470 #[tokio::test]
6475 async fn birth_refound_seeds_an_explicit_roster() {
6476 let (bed, owner, _m) = TestBed::new();
6477 bed.swap_to(&owner);
6478 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
6479 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
6480 assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
6481 let ghost_a = Keys::generate().public_key();
6483 let ghost_b = Keys::generate().public_key();
6484
6485 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
6486 assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
6487
6488 let members = memberlist(&bed.relay, &rolled).await.unwrap();
6491 assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
6492 assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
6493
6494 let (_, _, _banlist) = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
6498 .expect("the epoch-1 twin verifies from its compacted control plane");
6499
6500 let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
6503 assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
6504 assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
6505 }
6506
6507 #[tokio::test]
6511 async fn birth_refound_ignores_a_banned_seed_entry() {
6512 let (bed, owner, _m) = TestBed::new();
6513 bed.swap_to(&owner);
6514 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
6515 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
6516 let good = Keys::generate().public_key();
6517 let banned = Keys::generate();
6518 set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
6520 let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
6521
6522 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
6523 .expect("a banned seed entry is filtered, not a permanent verify-back wedge");
6524 assert_eq!(rolled.root_epoch, Epoch(1));
6525 let members = memberlist(&bed.relay, &rolled).await.unwrap();
6526 assert!(members.contains(&good), "the non-banned seed lands");
6527 assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
6528 }
6529
6530 #[tokio::test]
6536 async fn a_seeded_member_receives_a_later_refound_rekey() {
6537 let (bed, owner, _m) = TestBed::new();
6538 bed.swap_to(&owner);
6539 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
6540 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
6541 let ghost = Keys::generate();
6542 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
6544 assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
6545
6546 let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
6552 assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
6553 assert!(
6554 memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
6555 "a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
6556 );
6557 }
6558
6559 #[tokio::test]
6562 async fn v1_admin_stays_admin_across_migration() {
6563 use crate::community::migration;
6564 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6565 let (bed, owner, admin) = TestBed::new();
6566 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6567
6568 bed.swap_to(&owner);
6569 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6570 let v1_cid = v1.id.to_hex();
6571 v1.owner_attestation = Some({
6572 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6573 .finalize(&owner.keys).unwrap().as_json()
6574 });
6575 crate::db::community::save_community(&v1).unwrap();
6576 let admin_role = Role::admin("a1".repeat(32));
6578 let roles = CommunityRoles {
6579 roles: vec![admin_role.clone()],
6580 grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
6581 };
6582 crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
6583
6584 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6585 let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
6586
6587 let authority = fetch_authority(&bed.relay, &twin).await;
6589 assert!(
6590 authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
6591 "the v1 admin is an admin on the v2 twin"
6592 );
6593 assert!(
6594 !authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
6595 "a non-admin gains no authority"
6596 );
6597 }
6598
6599 #[tokio::test]
6604 async fn sweep_marks_checked_only_on_an_owner_tombstone() {
6605 use crate::community::migration;
6606 let (bed, owner, stranger) = TestBed::new();
6607
6608 bed.swap_to(&owner);
6609 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6610 let v1_cid = v1.id.to_hex();
6611 v1.owner_attestation = Some({
6612 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6613 .finalize(&owner.keys).unwrap().as_json()
6614 });
6615 crate::db::community::save_community(&v1).unwrap();
6616
6617 let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
6620 let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
6621 bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
6622 crate::db::community::set_community_dissolved(&v1_cid).unwrap();
6623
6624 migration::sweep_dissolved_for_migration(&bed.relay).await;
6626 assert!(
6627 crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
6628 "a stranger-only probe must not converge the sweep"
6629 );
6630
6631 let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
6633 let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
6634 bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
6635 migration::sweep_dissolved_for_migration(&bed.relay).await;
6636 assert!(
6637 !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
6638 "an owner plain-dissolution converges the sweep"
6639 );
6640 }
6641
6642 #[tokio::test]
6644 async fn wizard_preflight_gates_timelock_and_ownership() {
6645 use crate::community::migration;
6646 let (bed, owner, _member) = TestBed::new();
6647 bed.swap_to(&owner);
6648 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6649 v1.owner_attestation = Some({
6650 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
6651 .finalize(&owner.keys).unwrap().as_json()
6652 });
6653 crate::db::community::save_community(&v1).unwrap();
6654
6655 let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
6657 assert!(err.contains("not unlocked"), "{err}");
6658 assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
6659 }
6660
6661 #[tokio::test]
6662 async fn public_link_full_loop() {
6663 let (bed, owner, member) = TestBed::new();
6664
6665 bed.swap_to(&owner);
6666 let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
6667 let general = community.channels[0].id;
6668 send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
6669 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6671 assert!(link.url.starts_with("https://vectorapp.io/invite/"));
6672 assert!(link.url.contains('#'), "the fragment carries the token");
6673
6674 bed.swap_to(&member);
6676 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
6677 assert_eq!(joined.id().0, community.id().0);
6678 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
6679 }
6680
6681 #[test]
6682 fn bundle_of_snapshots_the_held_icon() {
6683 let owner = Keys::generate();
6684 let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
6685 let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
6686 let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
6687 c.icon = Some(icon.clone());
6688 let bundle = bundle_of(&c, BundleAudience::Link, None, None, None);
6689 assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
6690 }
6691
6692 #[test]
6693 fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
6694 let (_tmp, _guard, _owner) = init_test_db();
6699 let cur_root = [9u8; 32];
6700 let cid = crate::community::CommunityId([1u8; 32]);
6701 let cid_hex = cid.to_hex();
6702
6703 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
6705 assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
6706
6707 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
6709 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
6710 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
6711 assert_eq!(roots[0], cur_root, "current root leads");
6712 assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
6713 assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
6714 assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
6716
6717 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
6719 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
6720 assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
6721
6722 for e in 3..20u64 {
6724 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
6725 }
6726 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
6727 assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
6728 }
6729
6730 #[tokio::test]
6731 async fn public_link_preview_shows_live_name_and_icon_without_joining() {
6732 let (bed, owner, member) = TestBed::new();
6733
6734 bed.swap_to(&owner);
6735 let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
6736 let icon = control::ImageRef {
6739 url: "https://blossom.example/soap".into(),
6740 key: "k".into(),
6741 nonce: "n".into(),
6742 hash: "h".into(),
6743 extra: Default::default(),
6744 };
6745 let mut meta = community.metadata();
6746 meta.icon = Some(icon.clone());
6747 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
6748 let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
6750
6751 bed.swap_to(&member);
6753 let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
6754 assert_eq!(preview.name, "Soapbox");
6755 assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
6756 assert!(
6757 crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
6758 "previewing must not persist a membership"
6759 );
6760 }
6761
6762 #[tokio::test]
6763 async fn a_previewed_join_reuses_the_verified_fold() {
6764 let (bed, owner, member) = TestBed::new();
6765 bed.swap_to(&owner);
6766 let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
6767 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6768
6769 bed.swap_to(&member);
6770 let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
6771 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
6772 assert_eq!(joined.id().0, community.id().0);
6773 assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
6774 assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
6777 }
6778
6779 #[tokio::test]
6780 async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
6781 let (bed, owner, member) = TestBed::new();
6782 bed.swap_to(&owner);
6783 let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
6784 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6785
6786 bed.swap_to(&member);
6787 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
6788
6789 let session = SessionGuard::capture();
6791 assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the seed folds fresh events");
6792 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
6793 let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
6794 assert!(cursor > 0, "the cursor advanced past zero");
6795 let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
6796 let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
6797 assert_eq!(stored, live, "stored fold == live fold after the seed");
6798 assert!(stored.contains(&member.keys.public_key()));
6799
6800 assert!(sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty());
6802
6803 bed.swap_to(&owner);
6805 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
6806 bed.swap_to(&member);
6807 let session = SessionGuard::capture();
6808 assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the kick lands incrementally");
6809 assert!(
6810 !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
6811 "an owner kick removes the member from the stored fold"
6812 );
6813 }
6814
6815 #[tokio::test]
6816 async fn a_preview_then_revoke_still_refuses_the_join() {
6817 let (bed, owner, member) = TestBed::new();
6818 bed.swap_to(&owner);
6819 let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
6820 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6821
6822 bed.swap_to(&member);
6824 let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
6825 assert_eq!(p.name, "RevokeRace");
6826 bed.swap_to(&owner);
6827 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6828 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
6829
6830 bed.swap_to(&member);
6833 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
6834 assert!(err.contains("revoked"), "got: {err}");
6835 }
6836
6837 #[tokio::test]
6838 async fn a_revoked_link_refuses_to_join() {
6839 let (bed, owner, member) = TestBed::new();
6840 bed.swap_to(&owner);
6841 let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
6842 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6843 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6845 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
6846
6847 bed.swap_to(&member);
6848 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
6849 assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
6850 }
6851
6852 #[tokio::test]
6853 async fn an_expired_direct_invite_refuses_to_join() {
6854 let (bed, owner, member) = TestBed::new();
6855 bed.swap_to(&owner);
6856 let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
6857 let inviter = owner.keys.clone();
6859 let mut bundle = bundle_of(&community, BundleAudience::Link, Some(inviter.public_key()), Some(1_000), None);
6860 bundle.expires_at = Some(1_000); let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
6862 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
6863
6864 bed.swap_to(&member);
6865 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6866 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6867 assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
6868 }
6869
6870 #[tokio::test]
6871 async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
6872 let (bed, owner, member) = TestBed::new();
6876 bed.swap_to(&owner);
6877 let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
6878 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6879 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6880
6881 let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
6884
6885 bed.swap_to(&member);
6886 let err = accept_public_link(&union, &link.url).await.unwrap_err();
6887 assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
6888 }
6889
6890 #[test]
6891 fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
6892 let owner = Keys::generate();
6898 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
6899 let hex = crate::simd::hex::bytes_to_hex_32;
6900 let root = [0x11u8; 32];
6901 let mut bundle = CommunityInvite {
6902 community_id: hex(&identity.community_id.0),
6903 owner: hex(&identity.owner_xonly),
6904 owner_salt: hex(&identity.owner_salt),
6905 community_root: hex(&root),
6906 root_epoch: 0,
6907 channels: vec![],
6908 relays: vec!["wss://r".into()],
6909 name: "X".into(),
6910 icon: None,
6911 expires_at: None,
6912 creator_npub: None,
6913 label: None,
6914 extra: Default::default(),
6915 };
6916 bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
6917 .map(|i| {
6918 let mut id = [0u8; 32];
6919 id[..8].copy_from_slice(&(i as u64).to_be_bytes());
6920 invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
6921 })
6922 .collect();
6923 assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
6924 }
6925
6926 #[tokio::test]
6927 async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
6928 let (bed, owner, member) = TestBed::new();
6933 bed.swap_to(&owner);
6934 let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
6935 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6936 let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
6938 swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
6939
6940 bed.swap_to(&member);
6941 let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
6942 assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
6943 assert!(
6944 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6945 "the aborted join wrote nothing to the (member) account DB"
6946 );
6947 }
6948
6949 #[tokio::test]
6950 async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
6951 let (_tmp, _guard, owner) = init_test_db();
6954 let relay = MemoryRelay::new();
6955 let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
6956 let empty = MemoryRelay::new();
6959 let members = memberlist(&empty, &community).await.unwrap();
6960 assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
6961 }
6962
6963 #[tokio::test]
6964 async fn an_expiring_minted_invite_refuses_after_the_deadline() {
6965 let (bed, owner, member) = TestBed::new();
6968 bed.swap_to(&owner);
6969 let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
6970 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
6971 .await
6972 .unwrap();
6973
6974 bed.swap_to(&member);
6975 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6976 assert!(
6977 accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
6978 "a minted expiring invite refuses past its deadline"
6979 );
6980 }
6981
6982 #[tokio::test]
6983 async fn a_member_who_leaves_drops_from_the_memberlist() {
6984 let (bed, owner, member) = TestBed::new();
6985 bed.swap_to(&owner);
6986 let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
6987 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6988
6989 bed.swap_to(&member);
6990 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6991 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6992 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
6994 leave_community(&bed.relay, &joined).await.unwrap();
6995
6996 bed.swap_to(&owner);
6997 let members = memberlist(&bed.relay, &community).await.unwrap();
6998 assert!(members.contains(&owner.keys.public_key()));
6999 assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
7000 }
7001
7002 #[tokio::test]
7003 async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
7004 let (bed, owner, member) = TestBed::new();
7007 bed.swap_to(&owner);
7008 let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
7009 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
7010
7011 bed.swap_to(&member);
7012 assert!(
7013 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
7014 "the owner's community must be invisible in the member's account DB"
7015 );
7016 assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
7017 }
7018
7019 async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
7027 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7028 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
7029 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
7030 let mut head: Option<(u64, [u8; 32])> = None;
7031 for w in &wraps {
7032 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
7033 if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
7034 head = Some((ed.version, ed.self_hash));
7035 }
7036 }
7037 }
7038 head.map(|(_, h)| h)
7039 }
7040
7041 async fn cite_on_relay(
7046 relay: &MemoryRelay,
7047 community: &CommunityV2,
7048 signer: &Keys,
7049 ) -> Option<crate::community::edition::AuthorityCitation> {
7050 if community.owner().ok() == Some(signer.public_key()) {
7051 return None;
7052 }
7053 let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
7054 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7055 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
7056 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
7057 let mut head: Option<(u64, [u8; 32])> = None;
7058 for w in &wraps {
7059 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
7060 if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
7061 head = Some((ed.version, ed.self_hash));
7062 }
7063 }
7064 }
7065 head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
7066 }
7067
7068 async fn publish_channel_edition(
7069 relay: &MemoryRelay,
7070 community: &CommunityV2,
7071 signer: &Keys,
7072 channel_id: &ChannelId,
7073 name: &str,
7074 private: bool,
7075 version: u64,
7076 deleted: bool,
7077 ) {
7078 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7079 let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
7080 let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
7081 let content = serde_json::to_string(&meta).unwrap();
7082 let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
7083 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7084 relay.publish(&wrap, &community.relays).await.unwrap();
7085 }
7086
7087 async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
7090 publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
7091 }
7092
7093 async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
7096 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7097 let prev = head_hash_on_relay(relay, community, &community.id().0).await;
7098 let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
7099 let content = serde_json::to_string(&meta).unwrap();
7100 let cite = cite_on_relay(relay, community, signer).await;
7101 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());
7102 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
7103 relay.publish(&wrap, &community.relays).await.unwrap();
7104 }
7105
7106 #[test]
7107 fn metadata_apply_captures_undriven_fields_for_republish() {
7108 let owner = Keys::generate();
7109 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
7110 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
7111 let general = held.channels[0].id;
7112
7113 let mut custom = serde_json::Map::new();
7115 custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
7116 let mut extra = serde_json::Map::new();
7117 extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
7118 let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
7119 assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
7120 assert_eq!(held.meta_custom, Some(custom.clone()));
7121 assert_eq!(held.meta_extra, extra);
7122 assert_eq!(held.metadata().custom, Some(custom));
7124 assert_eq!(held.metadata().extra, held.meta_extra);
7125
7126 let mut ch_custom = serde_json::Map::new();
7128 ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
7129 let ch_meta = control::ChannelMetadata {
7130 name: "general".into(),
7131 private: false,
7132 voice: Some(true),
7133 deleted: None,
7134 custom: Some(ch_custom.clone()),
7135 extra: Default::default(),
7136 };
7137 assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
7138 let ch = held.channel(&general).unwrap();
7139 assert_eq!(ch.voice, Some(true));
7140 assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
7141 let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
7142 assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
7143 assert_eq!(rename.custom, Some(ch_custom));
7144 }
7145
7146 #[test]
7147 fn community_metadata_apply_sets_and_clears_images() {
7148 let owner = Keys::generate();
7149 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
7150 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
7151
7152 let icon = control::ImageRef {
7153 url: "https://blossom.example/i".into(),
7154 key: "k".into(),
7155 nonce: "n".into(),
7156 hash: "h".into(),
7157 extra: Default::default(),
7158 };
7159 let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
7160 assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
7161 assert_eq!(held.icon.as_ref(), Some(&icon));
7162
7163 let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
7165 assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
7166 assert_eq!(held.icon, None);
7167 }
7168
7169 async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
7171 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7172 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
7173 let prev = head_hash_on_relay(relay, community, &role_id).await;
7174 let content = crate::community::v2::roles::role_content_json(role).unwrap();
7175 let cite = cite_on_relay(relay, community, signer).await;
7176 let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7177 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7178 relay.publish(&wrap, &community.relays).await.unwrap();
7179 }
7180
7181 async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
7183 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7184 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
7185 let prev = head_hash_on_relay(relay, community, &eid).await;
7186 let grant = MemberGrant { member: member.to_hex(), role_ids };
7187 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
7188 let cite = cite_on_relay(relay, community, signer).await;
7189 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7190 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7191 relay.publish(&wrap, &community.relays).await.unwrap();
7192 }
7193
7194 async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
7196 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7197 let eid = crate::community::v2::derive::banlist_locator(community.id());
7198 let prev = head_hash_on_relay(relay, community, &eid).await;
7199 let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
7200 let cite = cite_on_relay(relay, community, signer).await;
7201 let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7202 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7203 relay.publish(&wrap, &community.relays).await.unwrap();
7204 }
7205
7206 fn admin_role(role_id: &str, perms: u64) -> Role {
7207 Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
7208 }
7209
7210 #[tokio::test]
7216 async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
7217 let (bed, owner, attacker) = TestBed::new();
7218 bed.swap_to(&owner);
7219 let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
7220 let victim = Keys::generate().public_key();
7221 grant_admin(&bed.relay, &community, &victim).await.unwrap();
7222
7223 let admin_rid = fetch_authority(&bed.relay, &community)
7225 .await
7226 .roles
7227 .roles
7228 .iter()
7229 .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
7230 .unwrap()
7231 .role_id
7232 .clone();
7233 publish_role(
7235 &bed.relay,
7236 &community,
7237 &attacker.keys,
7238 &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
7239 2,
7240 )
7241 .await;
7242
7243 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
7244 assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
7245 assert!(
7246 authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
7247 "the floor advances only to the AUTHORIZED head (owner v1)"
7248 );
7249 assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
7250 }
7251
7252 #[tokio::test]
7253 async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
7254 let (bed, owner, attacker) = TestBed::new();
7255 bed.swap_to(&owner);
7256 let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
7257 let victim = Keys::generate();
7258 grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
7259
7260 publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
7262
7263 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
7264 assert!(
7265 authority.roles.is_admin(&victim.public_key().to_hex()),
7266 "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
7267 );
7268 }
7269
7270 #[tokio::test]
7271 async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
7272 let (bed, owner, attacker) = TestBed::new();
7273 bed.swap_to(&owner);
7274 let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
7275 let victim = Keys::generate().public_key();
7276 grant_admin(&bed.relay, &community, &victim).await.unwrap();
7277
7278 for i in 0u8..6 {
7281 let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
7282 publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7283 }
7284
7285 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
7286 assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
7287 assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
7288 }
7289
7290 fn authority_fingerprint(a: &AuthoritySet) -> String {
7293 let mut roles = a.roles.roles.clone();
7294 roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
7295 let mut grants = a.roles.grants.clone();
7296 for g in &mut grants {
7297 g.role_ids.sort();
7298 }
7299 grants.sort_by(|x, y| x.member.cmp(&y.member));
7300 let banned: Vec<&String> = a.banned.iter().collect();
7301 serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
7302 }
7303
7304 #[tokio::test]
7305 async fn the_v2_authority_fold_is_order_independent() {
7306 let (bed, owner, _a) = TestBed::new();
7312 bed.swap_to(&owner);
7313 let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
7314
7315 let admin1 = Keys::generate().public_key();
7318 let admin2 = Keys::generate().public_key();
7319 grant_admin(&bed.relay, &community, &admin1).await.unwrap();
7320 grant_admin(&bed.relay, &community, &admin2).await.unwrap();
7321 let mod_rid = "5c".repeat(32);
7322 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
7323 let member = Keys::generate().public_key();
7324 publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
7325 let banned_member = Keys::generate().public_key();
7326 publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
7327 set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
7328 let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
7329 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
7330 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
7331
7332 let editions = fetch_control(&bed.relay, &community).await;
7333 let floors = load_floors(&community);
7334 assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
7335
7336 let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
7337
7338 let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
7341 let mut rev = editions.clone();
7342 rev.reverse();
7343 orders.push(rev);
7344 for shift in [1usize, 3, 5, 7] {
7345 let n = editions.len();
7346 orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
7347 }
7348 let mut zip = Vec::with_capacity(editions.len());
7350 let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
7351 while lo <= hi {
7352 zip.push(editions[lo as usize].clone());
7353 if lo != hi {
7354 zip.push(editions[hi as usize].clone());
7355 }
7356 lo += 1;
7357 hi -= 1;
7358 }
7359 orders.push(zip);
7360
7361 for (i, order) in orders.iter().enumerate() {
7362 let got = authority_fingerprint(&fold_authority(&community, order, &floors));
7363 assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
7364 }
7365 assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
7368 assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
7369 }
7370
7371 struct FetchErrors(MemoryRelay);
7373 #[async_trait::async_trait]
7374 impl crate::community::transport::Transport for FetchErrors {
7375 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
7376 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
7377 self.0.publish(e, r).await
7378 }
7379 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
7380 Err("relay down".to_string())
7381 }
7382 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
7383 self.0.publish_durable(e, r).await
7384 }
7385 }
7386
7387 #[tokio::test]
7388 async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
7389 let (bed, owner, victim) = TestBed::new();
7390 bed.swap_to(&owner);
7391 let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
7392 let victim_hex = victim.keys.public_key().to_hex();
7393 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7395 crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
7396
7397 let down = FetchErrors(MemoryRelay::new());
7400 let view = fetch_authority(&down, &community).await;
7401 assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
7402 }
7403
7404 #[tokio::test]
7405 async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
7406 let (bed, owner, _m) = TestBed::new();
7407 bed.swap_to(&owner);
7408 let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
7409 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7410 let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
7411 let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
7412
7413 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7415 publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
7416 publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
7417 let session = crate::state::SessionGuard::capture();
7418 follow_control(&bed.relay, &community, &session).await.unwrap();
7419 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
7420
7421 let relay2 = MemoryRelay::new();
7425 publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
7426 follow_control(&relay2, &community, &session).await.unwrap();
7427 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7428 assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
7429 }
7430
7431 #[tokio::test]
7432 async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
7433 let (_tmp, _guard, owner) = init_test_db();
7438 let relay = MemoryRelay::new();
7439 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
7440 let admin = Keys::generate();
7441 let rid = "a7".repeat(32);
7442 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
7443 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
7444
7445 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7447 let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
7448 let rumor = control::build_edition_rumor(
7449 admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
7450 head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
7451 &serde_json::to_string(&meta).unwrap(), 1_000, None,
7452 );
7453 let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
7454 relay.publish(&wrap, &community.relays).await.unwrap();
7455
7456 let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
7457 let victim = Keys::generate().public_key().to_hex();
7458 let ban_rumor = control::build_edition_rumor(
7459 admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
7460 &serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
7461 );
7462 let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
7463 relay.publish(&ban_wrap, &community.relays).await.unwrap();
7464
7465 let session = SessionGuard::capture();
7466 let updated = follow_control(&relay, &community, &session).await.unwrap();
7467 assert!(
7468 updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
7469 "an uncited metadata edit must not be honored",
7470 );
7471 let authority = fetch_authority(&relay, &community).await;
7472 assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
7473 }
7480
7481 #[tokio::test]
7482 async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
7483 let (_tmp, _guard, owner) = init_test_db();
7487 let relay = MemoryRelay::new();
7488 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
7489 let admin = Keys::generate();
7490 let rid = "a1".repeat(32);
7491 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
7492 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
7493 publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
7494
7495 let session = SessionGuard::capture();
7496 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
7497 assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
7498
7499 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
7501 let _ = follow_control(&relay, &community, &session).await.unwrap();
7502 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7503 assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
7504 }
7505
7506 #[tokio::test]
7507 async fn a_roleless_member_cannot_edit_metadata() {
7508 let (_tmp, _guard, _owner) = init_test_db();
7509 let relay = MemoryRelay::new();
7510 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
7511 let stranger = Keys::generate();
7512 publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
7513 let session = SessionGuard::capture();
7514 assert!(
7515 follow_control(&relay, &community, &session).await.unwrap().is_none(),
7516 "a roleless member's metadata edit never folds"
7517 );
7518 }
7519
7520 #[tokio::test]
7521 async fn a_self_signed_grant_is_not_authority() {
7522 let (_tmp, _guard, _owner) = init_test_db();
7526 let relay = MemoryRelay::new();
7527 let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
7528 let rogue = Keys::generate();
7529 let rid = "b2".repeat(32);
7530 publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7531 publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
7532 publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
7533 let session = SessionGuard::capture();
7534 assert!(
7535 follow_control(&relay, &community, &session).await.unwrap().is_none(),
7536 "a self-signed grant confers no authority"
7537 );
7538 }
7539
7540 #[tokio::test]
7541 async fn the_banlist_is_enforced_only_from_a_ban_holder() {
7542 let (_tmp, _guard, owner) = init_test_db();
7543 let relay = MemoryRelay::new();
7544 let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
7545 let target = "cc".repeat(32);
7546
7547 let rogue = Keys::generate();
7549 publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
7550 let floors = load_floors(&community);
7551 let editions = fetch_control(&relay, &community).await;
7552 let authority = fold_authority(&community, &editions, &floors);
7553 assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
7554
7555 publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
7557 let editions = fetch_control(&relay, &community).await;
7558 let authority = fold_authority(&community, &editions, &floors);
7559 assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
7560 }
7561
7562 #[tokio::test]
7563 async fn a_banned_admin_loses_all_authority() {
7564 let (_tmp, _guard, owner) = init_test_db();
7567 let relay = MemoryRelay::new();
7568 let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
7569 let admin = Keys::generate();
7570 let rid = "e5".repeat(32);
7571 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
7572 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
7573 publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
7575
7576 let session = SessionGuard::capture();
7577 assert!(
7578 follow_control(&relay, &community, &session).await.unwrap().is_none(),
7579 "a banned admin's edit is dropped even with an unstripped grant"
7580 );
7581 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
7582 assert!(authority.banned.contains(&admin.public_key().to_hex()));
7583 assert!(
7584 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
7585 "a banned admin holds no bit"
7586 );
7587 }
7588
7589 #[tokio::test]
7590 async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
7591 let (_tmp, _guard, owner) = init_test_db();
7595 let relay = MemoryRelay::new();
7596 let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
7597 let admin = Keys::generate();
7598 let moder = Keys::generate();
7599 let stranger = Keys::generate();
7600 let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
7601 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;
7602 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;
7603 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
7604 publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
7605 publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
7606
7607 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
7608 assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
7609 assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
7610 assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
7611 }
7612
7613 #[tokio::test]
7614 async fn an_unauthorized_higher_banlist_cannot_unban() {
7615 let (_tmp, _guard, owner) = init_test_db();
7619 let relay = MemoryRelay::new();
7620 let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
7621 let target = "cc".repeat(32);
7622 publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
7623 let session = SessionGuard::capture();
7624 follow_control(&relay, &community, &session).await.unwrap(); let rogue = Keys::generate();
7627 publish_banlist(&relay, &community, &rogue, &[], 2).await; let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
7629 assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
7630 }
7631
7632 #[tokio::test]
7633 async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
7634 let (_tmp, _guard, _owner) = init_test_db();
7637 let relay = MemoryRelay::new();
7638 let relays = vec!["wss://r".to_string()];
7639 let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
7640 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
7641 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
7642
7643 let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
7644 assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
7645 assert_eq!(rehydrated[0].id().0, community.id().0);
7646 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
7647 }
7648
7649 #[tokio::test]
7650 async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
7651 let (_tmp, _guard, _owner) = init_test_db();
7652 let relay = MemoryRelay::new();
7653 let relays = vec!["wss://r".to_string()];
7654 let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
7655 leave_community(&relay, &community).await.unwrap(); let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
7658 assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
7659 }
7660
7661 #[tokio::test]
7662 async fn accepting_the_same_bundle_twice_is_idempotent() {
7663 let (bed, owner, member) = TestBed::new();
7667 bed.swap_to(&owner);
7668 let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
7669 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
7670 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7671 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
7672
7673 bed.swap_to(&member);
7674 let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
7675 let channels_after_first = first.channels.len();
7676 let root_after_first = first.community_root;
7677
7678 let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
7680 assert_eq!(second.id().0, first.id().0, "same community_id");
7681 assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
7682 assert_eq!(second.community_root, root_after_first, "root unchanged");
7683
7684 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7686 assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
7687 assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
7688 }
7689
7690 #[tokio::test]
7691 async fn a_severed_member_can_be_unbanned_and_re_admitted() {
7692 let (bed, owner, member) = TestBed::new();
7697 bed.swap_to(&owner);
7698 let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
7699 let general = community.channels[0].id;
7700 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
7701 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
7702
7703 bed.swap_to(&member);
7704 let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7705 let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
7706 assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
7707
7708 bed.swap_to(&owner);
7710 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
7711 grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
7712 community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
7713 assert_eq!(community.root_epoch, Epoch(1));
7714 send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
7715
7716 bed.swap_to(&member);
7718 let session = SessionGuard::capture();
7719 assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
7720
7721 bed.swap_to(&owner);
7725 set_banlist(&bed.relay, &community, &[]).await.unwrap();
7726 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7727 assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
7728 let fresh_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
7729
7730 bed.swap_to(&member);
7732 let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
7733 assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
7734 assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
7735 let seen = texts_in(&bed.relay, &rejoined, &general).await;
7736 assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
7737 send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
7738
7739 bed.swap_to(&owner);
7740 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7741 assert!(
7742 texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
7743 "the re-admitted member converses again at the new epoch"
7744 );
7745 let members = memberlist(&bed.relay, &community).await.unwrap();
7747 assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
7748 }
7749
7750 #[tokio::test]
7751 async fn dissolution_blocks_a_join() {
7752 let (bed, owner, member) = TestBed::new();
7755 bed.swap_to(&owner);
7756 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
7757 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7758 let bundle_json = serde_json::to_string(&bundle).unwrap();
7759 dissolve_community(&bed.relay, &community).await.unwrap();
7760 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
7761
7762 bed.swap_to(&member);
7763 let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
7764 assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
7765 }
7766
7767 #[tokio::test]
7768 async fn dissolution_seals_writes_but_not_reads() {
7769 let (bed, owner, _member) = TestBed::new();
7772 bed.swap_to(&owner);
7773 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
7774 let general = community.channels[0].id;
7775 send_message(&bed.relay, &community, &general, "before the end").await.unwrap();
7776
7777 dissolve_community(&bed.relay, &community).await.unwrap();
7778 let sealed = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7779
7780 for err in [
7781 send_message(&bed.relay, &sealed, &general, "after the end").await.unwrap_err(),
7782 send_reaction(&bed.relay, &sealed, &general, &"a".repeat(64), &"b".repeat(64), crate::community::v2::kind::MESSAGE, "+", None)
7783 .await
7784 .unwrap_err(),
7785 send_edit(&bed.relay, &sealed, &general, &"a".repeat(64), "revised").await.unwrap_err(),
7786 ] {
7787 assert!(err.contains("dissolved"), "every write is refused, got: {err}");
7788 }
7789 assert!(
7790 texts_in(&bed.relay, &sealed, &general).await.contains(&"before the end".to_string()),
7791 "but the history still reads"
7792 );
7793 }
7794
7795 #[tokio::test]
7796 async fn only_the_owner_can_dissolve() {
7797 let (bed, owner, member) = TestBed::new();
7798 bed.swap_to(&owner);
7799 let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
7800 bed.swap_to(&member);
7801 assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
7802 assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
7803 }
7804
7805 #[tokio::test]
7806 async fn a_foreign_tombstone_is_not_death() {
7807 let (_tmp, _guard, _owner) = init_test_db();
7810 let relay = MemoryRelay::new();
7811 let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
7812 let rogue = Keys::generate();
7813 let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
7814 let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
7815 relay.publish(&wrap, &community.relays).await.unwrap();
7816 assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
7817 }
7818
7819 #[tokio::test]
7820 async fn a_public_channel_reads_history_across_a_refounding() {
7821 let (_tmp, _guard, _owner) = init_test_db();
7825 let relay = MemoryRelay::new();
7826 let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
7827 let general = community.channels[0].id;
7828 send_message(&relay, &community, &general, "before the refounding").await.unwrap();
7829
7830 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
7831 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
7832 send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
7833
7834 let texts = texts_in(&relay, &refounded, &general).await;
7835 assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
7836 assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
7837 }
7838
7839 #[tokio::test]
7840 async fn refounding_aborts_when_control_state_is_withheld() {
7841 let (_tmp, _guard, owner) = init_test_db();
7845 let relay = MemoryRelay::new();
7846 let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
7847 publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
7848 let session = SessionGuard::capture();
7849 follow_control(&relay, &community, &session).await.unwrap(); let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7854 moved.relays = vec!["wss://empty".into()];
7855 crate::db::community::save_community_v2(&moved).unwrap();
7856
7857 let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
7858 assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
7859 assert_eq!(
7860 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
7861 Epoch(0),
7862 "the epoch did NOT advance (zero published state)"
7863 );
7864 }
7865
7866 #[tokio::test]
7867 async fn refounding_rolls_the_root_and_severs_a_removed_member() {
7868 let (bed, owner, member) = TestBed::new();
7871 bed.swap_to(&owner);
7872 let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
7873 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7874 let bundle_json = serde_json::to_string(&bundle).unwrap();
7875 bed.swap_to(&member);
7876 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7877
7878 bed.swap_to(&owner);
7879 let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
7880 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
7881 assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
7882 assert_eq!(
7884 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
7885 Epoch(1),
7886 "the owner committed the new epoch"
7887 );
7888
7889 bed.swap_to(&member);
7893 let session = SessionGuard::capture();
7894 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
7895 assert!(follow.self_removed, "the removed member is cut by the re-founding");
7896 }
7897
7898 #[tokio::test]
7899 async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
7900 let (bed, owner, member) = TestBed::new();
7905 bed.swap_to(&owner);
7906 let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
7907 let rid = "b0".repeat(32);
7908 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7909 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
7910 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7911 let bundle_json = serde_json::to_string(&bundle).unwrap();
7912 bed.swap_to(&member);
7913 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7914 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7917 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7918 assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
7920 assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
7922 }
7923
7924 #[tokio::test]
7925 async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
7926 let (bed, owner, me) = TestBed::new();
7931 let admin = Keys::generate();
7932 bed.swap_to(&owner);
7933 let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
7934 let rid = "b0".repeat(32);
7935 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7936 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
7937
7938 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7940 let bundle_json = serde_json::to_string(&bundle).unwrap();
7941 bed.swap_to(&me);
7942 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7943 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7944 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7945
7946 let new_root = [0xC7; 32];
7949 publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
7950
7951 let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
7952 .expect("an authorized admin's Refounding is adopted");
7953 assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
7954 assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
7955 }
7956
7957 #[tokio::test]
7958 async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
7959 let (bed, owner, me) = TestBed::new();
7967 bed.swap_to(&owner);
7968 let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
7969 let rid = "b1".repeat(32);
7970 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7971 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
7972
7973 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7974 let bundle_json = serde_json::to_string(&bundle).unwrap();
7975 bed.swap_to(&me);
7976 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7977 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7978 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7979
7980 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
7982 let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
7983 assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
7984
7985 let new_root = [0xD4; 32];
7988 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
7989
7990 let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
7991 .expect("the owner's Refounding is adopted");
7992 assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
7993
7994 let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
7995 assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
7996 assert_eq!(
7997 crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
7998 new_root,
7999 "and the new root behind the same URL",
8000 );
8001 }
8002
8003 #[tokio::test]
8004 async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
8005 let (bed, owner, me) = TestBed::new();
8009 let admin = Keys::generate();
8010 bed.swap_to(&owner);
8011 let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
8012 let rid = "b0".repeat(32);
8013 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8014 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
8015
8016 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8017 let bundle_json = serde_json::to_string(&bundle).unwrap();
8018 bed.swap_to(&me);
8019 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8020 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8021 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8022
8023 publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
8025 let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8026 assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
8027 }
8028
8029 #[tokio::test]
8030 async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
8031 let (bed, owner, me) = TestBed::new();
8035 let admin_a = Keys::generate();
8036 let admin_b = Keys::generate(); bed.swap_to(&owner);
8038 let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
8039 let rid = "b0".repeat(32);
8040 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8041 publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
8043 publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
8044
8045 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8046 let bundle_json = serde_json::to_string(&bundle).unwrap();
8047 bed.swap_to(&me);
8048 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8049 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8050 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8051
8052 publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
8054
8055 let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8056 assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
8057 }
8058
8059 #[tokio::test]
8060 async fn a_retried_refounding_reuses_the_same_root() {
8061 let (_tmp, _guard, _owner) = init_test_db();
8064 let relay = MemoryRelay::new();
8065 let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
8066 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8067 let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
8068 let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
8069 assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
8070 }
8071
8072 #[tokio::test]
8073 async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
8074 let (bed, owner, attacker) = TestBed::new();
8081 bed.swap_to(&owner);
8082 let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
8083
8084 let senior = "a1".repeat(32);
8086 let mid = "a5".repeat(32);
8087 publish_role(&bed.relay, &community, &owner.keys,
8088 &Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
8089 publish_role(&bed.relay, &community, &owner.keys,
8090 &Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0 }, 1).await;
8091 publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
8092
8093 publish_role(&bed.relay, &community, &attacker.keys,
8095 &Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 2).await;
8096
8097 let authority = fetch_authority(&bed.relay, &community).await;
8098 let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
8099 assert_eq!(
8100 folded_senior.position, 1,
8101 "a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
8102 );
8103 }
8104
8105 #[tokio::test]
8106 async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
8107 let (bed, owner, admin) = TestBed::new();
8114 bed.swap_to(&owner);
8115 let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
8116 let rid = "c1".repeat(32);
8117 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
8118 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
8119
8120 let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
8122 edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
8123 let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
8124 .filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
8125 .max_by_key(|e| e.version).expect("the owner's metadata edition");
8126 assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
8127
8128 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8131 let bundle_json = serde_json::to_string(&bundle).unwrap();
8132 bed.swap_to(&admin);
8133 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8134 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8135 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8136 set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
8137
8138 let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
8139 .find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
8140 .expect("the admin's banlist edition");
8141 let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
8142 assert_eq!(
8143 cite.entity_id,
8144 crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
8145 "the citation must name the ACTOR'S OWN grant coordinate",
8146 );
8147 assert!(cite.version >= 1, "pinned to a real grant version");
8148 }
8149
8150 #[tokio::test]
8151 async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
8152 let (_tmp, _guard, _owner) = init_test_db();
8158 let relay = MemoryRelay::new();
8159 let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
8160
8161 let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
8162 let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
8163 edit_community_metadata(&relay, &community, &meta).await.unwrap();
8164
8165 let updated = follow_control(&relay, &community, &SessionGuard::capture()).await.unwrap()
8166 .expect("the metadata edition is folded");
8167 assert_eq!(
8168 updated.relays.len(),
8169 crate::community::MAX_COMMUNITY_RELAYS,
8170 "a folded relay list must be truncated, never adopted whole",
8171 );
8172
8173 let again = follow_control(&relay, &updated, &SessionGuard::capture()).await.unwrap();
8177 assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
8178 }
8179
8180 #[tokio::test]
8181 async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
8182 let (bed, owner, me) = TestBed::new();
8188 bed.swap_to(&owner);
8189 let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
8190 let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
8191 let rid = "b2".repeat(32);
8192 publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8193 publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
8194
8195 let bundle = bundle_of(&host, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8196 let bundle_json = serde_json::to_string(&bundle).unwrap();
8197 bed.swap_to(&me);
8198 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8199 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8200 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8201
8202 mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
8204
8205 let before = bed.relay.stored_count();
8206 let new_root = [0xE1; 32];
8207 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
8208 let rotation_events = bed.relay.stored_count() - before;
8209
8210 let after_adopt = bed.relay.stored_count();
8211 follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8212 assert_eq!(
8213 bed.relay.stored_count(),
8214 after_adopt,
8215 "adopting a rotation must publish NOTHING when I minted no links here",
8216 );
8217 assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
8218 }
8219
8220 #[tokio::test]
8221 async fn an_expired_link_stops_keeping_the_community_public() {
8222 let (_tmp, _guard, _owner) = init_test_db();
8228 let relay = MemoryRelay::new();
8229 let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
8230
8231 let past = now_ms() - 60_000;
8233 mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
8234 assert!(
8235 !community_is_public(&relay, &community).await,
8236 "an already-expired link must never read as a live door",
8237 );
8238
8239 mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
8241 assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
8242 }
8243
8244 #[tokio::test]
8245 async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
8246 let (_tmp, _guard, _owner) = init_test_db();
8249 let relay = MemoryRelay::new();
8250 let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
8251 assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
8252
8253 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
8254 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
8255 let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
8256 assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
8257
8258 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
8259 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
8260 assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
8261 let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
8262 assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
8263 }
8264
8265 #[tokio::test]
8266 async fn the_registry_is_cached_locally_so_public_private_is_a_sync_read() {
8267 let (_tmp, _guard, _owner) = init_test_db();
8271 let relay = MemoryRelay::new();
8272 let community = create_community(&relay, "Cached", vec!["wss://r".into()], None).await.unwrap();
8273 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8274 let cached = || crate::db::community::get_community_invite_registry(&cid_hex).unwrap();
8275 let per_creator = || crate::db::community::get_invite_link_sets(&cid_hex).unwrap();
8278 assert!(cached().is_empty(), "a fresh community caches an empty registry");
8279 assert!(per_creator().is_empty(), "…and no per-creator sets");
8280
8281 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
8282 assert!(!cached().is_empty(), "minting caches the registry, so the UI reads Public without folding");
8283 let sets = per_creator();
8284 assert_eq!(sets.len(), 1, "the minting creator gets a set");
8285 assert_eq!(sets[0].locators.len(), 1, "carrying exactly their one live link");
8286
8287 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
8289 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
8290 assert!(cached().is_empty(), "retiring the last link empties the cache back to Private");
8291 assert!(per_creator().is_empty(), "…and clears the per-creator sets");
8292 }
8293
8294 #[tokio::test]
8295 async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
8296 let (_tmp, _guard, owner) = init_test_db();
8303 let relay = MemoryRelay::new();
8304 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
8305 mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
8306 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
8307
8308 let cid = community.id();
8309 let control = control_group_key(&community.community_root, cid, community.root_epoch);
8310 let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
8311
8312 let query = Query {
8313 kinds: vec![stream::KIND_WRAP],
8314 authors: vec![control.pk_hex()],
8315 limit: Some(FOLLOW_PAGE),
8316 ..Default::default()
8317 };
8318 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
8319 let target = wraps
8320 .iter()
8321 .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
8322 .filter(|e| e.entity_id == eid)
8323 .max_by_key(|e| e.version)
8324 .expect("the owner published a registry");
8325
8326 let rogue = Keys::generate();
8329 let mut planted = false;
8330 for n in 0..4_000u64 {
8331 let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
8332 let rumor = control::build_edition_rumor(
8333 rogue.public_key(),
8334 vsk::INVITE_LINKS,
8335 &eid,
8336 target.version,
8337 target.prev_hash.as_ref(),
8338 &content,
8339 9_000,
8340 None,
8341 );
8342 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
8343 let (ed, _) = control::open_control_edition(&w, &control).unwrap();
8344 if ed.inner_id < target.inner_id {
8345 relay.publish(&w, &community.relays).await.unwrap();
8346 planted = true;
8347 break;
8348 }
8349 }
8350 assert!(planted, "the test needs a fork that wins the tiebreak");
8351
8352 assert!(
8353 community_is_public(&relay, &community).await,
8354 "an unauthorised fork must not retire the owner's live link"
8355 );
8356 }
8357
8358 #[tokio::test]
8359 async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
8360 let (_tmp, _guard, owner) = init_test_db();
8362 let relay = MemoryRelay::new();
8363 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
8364 let rogue = Keys::generate();
8365 let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
8367 let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
8368 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8369 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
8370 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
8371 relay.publish(&wrap, &community.relays).await.unwrap();
8372 let _ = owner;
8373 assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
8374 }
8375
8376 #[tokio::test]
8377 async fn full_lifecycle_e2e() {
8378 let (bed, owner, member) = TestBed::new();
8384
8385 bed.swap_to(&owner);
8386 let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
8387 let general = community.channels[0].id;
8388 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
8389
8390 let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
8392 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
8393
8394 let rid = "aa".repeat(32);
8396 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
8397 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
8398
8399 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8401 let bundle_json = serde_json::to_string(&bundle).unwrap();
8402 bed.swap_to(&member);
8403 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8404 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
8405 publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
8407
8408 bed.swap_to(&owner);
8410 let session = SessionGuard::capture();
8411 let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
8412 assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
8413
8414 set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
8416 grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
8417 let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
8418 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
8419 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
8421 assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
8422 assert!(
8424 texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
8425 "pre-refounding history stays readable"
8426 );
8427
8428 bed.swap_to(&member);
8431 let session = SessionGuard::capture();
8432 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8433 assert!(follow.self_removed, "the banned member is cryptographically cut");
8434
8435 bed.swap_to(&owner);
8437 dissolve_community(&bed.relay, &refounded).await.unwrap();
8438 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
8439 }
8440
8441 #[tokio::test]
8447 async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
8448 let (bed, owner, member) = TestBed::new();
8453 let attacker = Keys::generate();
8454 bed.swap_to(&owner);
8455 let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
8456 let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
8457 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
8458 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
8459 let session = SessionGuard::capture();
8461 follow_control(&bed.relay, &community, &session).await.unwrap();
8462 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
8463
8464 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;
8466
8467 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
8469 assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
8470
8471 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
8473 assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
8474 }
8475
8476 #[tokio::test]
8477 async fn memberlist_survives_a_refounding_via_the_snapshot() {
8478 let (bed, owner, member) = TestBed::new();
8481 bed.swap_to(&owner);
8482 let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
8483
8484 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
8486 bed.swap_to(&member);
8487 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
8488 bed.swap_to(&owner);
8489 assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
8490
8491 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
8493 assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
8494
8495 let members = memberlist(&bed.relay, &refounded).await.unwrap();
8497 assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
8498 assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
8499 }
8500
8501 #[tokio::test]
8502 async fn e2e_two_accounts_channels_converse_moderate() {
8503 use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
8504 use nostr_sdk::prelude::ToBech32;
8505 let (bed, a, b) = TestBed::new();
8506 let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
8507 let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
8508 println!("\n===== Concord v2 deep e2e =====");
8509 println!("[acct] A (owner) = {a_npub}");
8510 println!("[acct] B (member) = {b_npub}");
8511
8512 bed.swap_to(&a);
8514 let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
8515 let general = community.channels[0].id;
8516 println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
8517
8518 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
8522 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8523 let priv_ch = community.channel(&priv_id).unwrap();
8524 assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
8525 println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
8526
8527 let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
8529 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8530 let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
8531 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8532 println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
8533 assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
8534
8535 let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
8537 send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
8538 send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
8539 println!("[msg] A posted in #general / #announcements / #mods");
8540
8541 let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
8543 publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
8544 publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
8545 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8546 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
8547 println!("[invite] granted B @admin · minted link {}", link.url);
8548
8549 grant_channel_access(&bed.relay, &community, &priv_id, &b.keys.public_key()).await.unwrap();
8552 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(b.keys.public_key()), Some(a.keys.public_key()), None, None)).unwrap();
8553 bed.swap_to(&b);
8554 let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8555 println!("[join] B joined; sees {} channels", b_view.channels.len());
8556 assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
8557 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");
8558 assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
8559 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");
8560 let session_b = SessionGuard::capture();
8563 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
8564 b_view = fresh;
8565 }
8566 println!("[follow] B folded control (roster persisted: B is @admin)");
8567
8568 send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
8570 send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
8571 println!("[msg] B replied in #general + #mods");
8572 let my_pk = b.keys.public_key();
8575 let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
8576 for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
8577 let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
8578 if let Some(o) = outcome { persist_chat(&gh, &o).await; }
8579 }
8580 assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
8581 println!("[persist] #general history persisted into the shared events store");
8582
8583 send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
8585 bed.swap_to(&a);
8586 let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
8587 send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
8588 let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
8589 send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
8590 println!("[ops] reaction + edit + delete round-tripped");
8591
8592 bed.swap_to(&b);
8594 let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
8595 println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
8596 bed.swap_to(&a);
8597 let session = SessionGuard::capture();
8598 if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
8599 community = updated;
8600 }
8601 assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
8602 println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
8603
8604 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
8610 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8611 send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
8612 println!("[channel] +private #vault {} (B is unentitled — no delivery)", crate::simd::hex::bytes_to_hex_32(&vault.0));
8613 bed.swap_to(&b);
8614 let session_b2 = SessionGuard::capture();
8615 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
8616 b_view = fresh;
8617 }
8618 let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
8619 assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
8620 let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
8621 if let Some(fresh) = rf.updated {
8622 b_view = fresh;
8623 }
8624 let ch = b_view.channel(&vault).expect("still recorded");
8625 assert!(ch.key.is_none(), "an unentitled member is never delivered the key");
8626 assert!(
8627 texts_in(&bed.relay, &b_view, &vault).await.is_empty(),
8628 "and reads nothing from it"
8629 );
8630 assert!(
8631 send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.is_err(),
8632 "an unentitled member cannot post into the channel either"
8633 );
8634 bed.swap_to(&a);
8635 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8636 println!("[private] #vault stayed sealed to the unentitled B (no key, no read, no send)");
8637
8638 let members = memberlist(&bed.relay, &community).await.unwrap();
8640 let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
8641 assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
8642 println!("[members] {} members: A + B present", members.len());
8643
8644 set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
8646 grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
8647 let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
8648 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
8649 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
8650 assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
8651 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");
8652 assert!(
8653 texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
8654 "PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
8655 );
8656 println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
8657 bed.swap_to(&b);
8659 let session_b3 = SessionGuard::capture();
8660 assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
8661 println!("[ban] B's rekey-follow: self_removed = true (severed)");
8662
8663 bed.swap_to(&a);
8665 set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
8666 let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
8667 assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
8668 println!("[unban] B removed from the banlist (re-invitable)");
8669
8670 dissolve_community(&bed.relay, &refounded).await.unwrap();
8672 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
8673 println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
8674 }
8675
8676 #[tokio::test]
8682 #[ignore]
8683 async fn live_e2e_two_accounts() {
8684 use crate::community::transport::LiveTransport;
8685 use nostr_sdk::prelude::ToBech32;
8686
8687 let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
8688 let relays = vec![relay.clone()];
8689 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
8690 crate::db::close_database();
8691 crate::db::clear_id_caches();
8692 let tmp = tempfile::tempdir().unwrap();
8693 crate::db::set_app_data_dir(tmp.path().to_path_buf());
8694
8695 let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
8697 let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
8698
8699 let log = |line: String| {
8700 println!("{line}");
8701 if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
8702 use std::io::Write;
8703 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
8704 let _ = writeln!(f, "{line}");
8705 }
8706 }
8707 };
8708 log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
8709 log(format!("VECTOR_E2E_NSEC_A={} ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
8710 log(format!("VECTOR_E2E_NSEC_B={} ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
8711
8712 for k in [&a, &b] {
8713 let npub = k.public_key().to_bech32().unwrap();
8714 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
8715 crate::db::set_current_account(npub.clone()).unwrap();
8716 crate::db::init_database(&npub).unwrap();
8717 }
8718 let client = crate::nostr_client_builder().build();
8721 client.add_managed_relay(relay.as_str()).await.ok();
8722 client.connect().await;
8723 crate::state::set_nostr_client(client);
8724 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
8725 let become_acct = |k: &Keys| {
8726 let npub = k.public_key().to_bech32().unwrap();
8727 crate::db::set_current_account(npub.clone()).unwrap();
8728 crate::db::init_database(&npub).unwrap();
8729 crate::db::clear_id_caches();
8730 crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
8731 crate::state::set_my_public_key(k.public_key());
8732 };
8733 let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
8734
8735 become_acct(&a);
8737 let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
8738 let general = community.channels[0].id;
8739 log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
8740 send_message(&transport, &community, &general, "A: live hello").await.expect("send");
8741 let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
8742 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8743 log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
8744 grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
8745 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
8746 log(format!("[invite] B granted @admin · link {}", link.url));
8747 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(a.public_key()), None, None)).unwrap();
8748 settle().await;
8749
8750 become_acct(&b);
8752 let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
8753 log(format!("[join] B joined; {} channels", b_view.channels.len()));
8754 settle().await;
8755 let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
8756 let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
8757 log(format!("[read] B sees #general: {seen:?}"));
8758 assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
8759 send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
8760
8761 let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
8765 let hello_id = hello.event.opened().rumor_id.to_hex();
8766 let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
8767 let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
8768 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());
8769 let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
8770 transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
8771 log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
8772 settle().await;
8773
8774 become_acct(&a);
8776 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8777 let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
8778 let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
8779 if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
8780 assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
8781 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");
8782 } else {
8783 panic!("the 1111 parsed as a Message");
8784 }
8785 log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
8786 become_acct(&b);
8787 settle().await;
8788
8789 become_acct(&a);
8793 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8794 let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
8795 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8796 send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
8797 log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
8798 settle().await;
8799
8800 become_acct(&b);
8801 let session_b = SessionGuard::capture();
8802 let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
8803 if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
8804 b_view = fresh;
8805 }
8806 if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
8807 b_view = fresh;
8808 }
8809 let vch = b_view.channel(&vault).expect("B folded the vault");
8810 assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
8811 let vseen = texts_in(&transport, &b_view, &vault).await;
8812 log(format!("[read] B sees #vault: {vseen:?}"));
8813 assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
8814 send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
8815 settle().await;
8816
8817 become_acct(&a);
8818 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8819 assert!(
8820 texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
8821 "A reads B's private reply"
8822 );
8823 log("[private] two-way #vault conversation over the live relay".to_string());
8824
8825 set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
8827 grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
8828 let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
8829 log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
8830 settle().await;
8831 dissolve_community(&transport, &refounded).await.expect("dissolve");
8832 log("[dissolve] community sealed".to_string());
8833 log("===== LIVE e2e PASS =====".to_string());
8834 }
8835
8836 #[tokio::test]
8837 async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
8838 let (bed, owner, member) = TestBed::new();
8842 bed.swap_to(&owner);
8843 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
8844 let general = community.channels[0].id;
8845 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
8846
8847 bed.swap_to(&member);
8848 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
8849 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
8850
8851 bed.swap_to(&owner);
8853 dissolve_community(&bed.relay, &community).await.unwrap();
8854
8855 bed.swap_to(&member);
8857 let session = SessionGuard::capture();
8858 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8859 assert!(follow.dissolved, "the catch-up surfaces the tombstone");
8860 assert!(!follow.self_removed && follow.updated.is_none());
8861 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
8862 assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
8863 let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
8864 assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
8865 let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8867 assert!(again.dissolved && again.updated.is_none());
8868 }
8869
8870 #[tokio::test]
8871 async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
8872 const PRIV_CHANNELS: usize = 6;
8877 let (bed, owner, member) = TestBed::new();
8878 bed.swap_to(&owner);
8879 let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
8880 let mut priv_ids = Vec::new();
8881 for i in 0..PRIV_CHANNELS {
8882 let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
8883 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8884 send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
8885 priv_ids.push(id);
8886 }
8887 for id in &priv_ids {
8889 grant_channel_access(&bed.relay, &community, id, &member.keys.public_key()).await.unwrap();
8890 }
8891 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
8892
8893 bed.swap_to(&member);
8895 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8896 assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
8897
8898 bed.swap_to(&owner);
8900 for epoch in 1..=2u64 {
8901 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
8902 assert_eq!(community.root_epoch, Epoch(epoch));
8903 for id in &priv_ids {
8904 send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
8905 }
8906 }
8907
8908 bed.swap_to(&member);
8910 let session = SessionGuard::capture();
8911 let mut passes = 0;
8912 loop {
8913 passes += 1;
8914 assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
8915 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8916 let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
8917 assert!(!rk.self_removed);
8918 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8919 let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
8920 if rk.updated.is_none() && ctl.is_none() {
8921 break;
8922 }
8923 }
8924 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8925 assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
8926 for id in &priv_ids {
8928 let mine = caught_up.channel(id).expect("channel survived");
8929 let theirs = community.channel(id).unwrap();
8930 assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
8931 assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
8932 let texts = texts_in(&bed.relay, &caught_up, id).await;
8933 let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
8934 assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
8935 for epoch in 1..=2u64 {
8936 assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
8937 }
8938 }
8939 }
8940
8941 #[tokio::test]
8942 async fn an_offline_member_catches_up_across_three_refoundings() {
8943 use nostr_sdk::prelude::ToBech32;
8948 let (bed, owner, member) = TestBed::new();
8949 bed.swap_to(&owner);
8950 let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
8951 let general = community.channels[0].id;
8952 let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
8953 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8954 send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
8955 send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
8956 grant_channel_access(&bed.relay, &community, &mods, &member.keys.public_key()).await.unwrap();
8958 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
8959
8960 bed.swap_to(&member);
8962 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8963 assert_eq!(member_view.root_epoch, Epoch(0));
8964
8965 bed.swap_to(&owner);
8967 let stranger = Keys::generate();
8968 for epoch in 1..=3u64 {
8969 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
8970 assert_eq!(community.root_epoch, Epoch(epoch));
8971 send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
8972 send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
8973 }
8974 let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
8975 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8976 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
8977 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8978 grant_channel_access(&bed.relay, &community, &vault, &member.keys.public_key()).await.unwrap();
8981 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8982 send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
8983 set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
8984 let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
8985 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
8986
8987 bed.swap_to(&member);
8990 let session = SessionGuard::capture();
8991 let mut passes = 0;
8992 loop {
8993 passes += 1;
8994 assert!(passes <= 6, "catch-up must converge, not churn");
8995 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8996 let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
8997 assert!(!rekeyed.self_removed, "the member was never removed");
8998 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8999 let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
9000 if rekeyed.updated.is_none() && controlled.is_none() {
9001 break;
9002 }
9003 }
9004 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9005
9006 assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
9008 assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
9009 assert_eq!(caught_up.name, "Sleeper Reborn");
9010 assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
9012 let m = caught_up.channel(&mods).expect("mods survived");
9013 let owner_mods = community.channel(&mods).unwrap();
9014 assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
9015 assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
9016 let v = caught_up.channel(&vault).expect("vault folded in");
9017 assert!(v.private && v.key.is_none(), "vault folds in keyless: entitled, but never delivered");
9022 let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
9024 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
9025 assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
9026 let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
9029 for epoch in 0..=3u64 {
9030 let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
9031 assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
9032 }
9033 let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
9034 for epoch in 0..=3u64 {
9035 let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
9036 assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
9037 }
9038 assert!(texts_in(&bed.relay, &caught_up, &vault).await.is_empty());
9041 send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
9043 bed.swap_to(&owner);
9044 assert!(
9045 texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
9046 "the caught-up member converses at the new epoch ({})",
9047 member.keys.public_key().to_bech32().unwrap()
9048 );
9049 }
9050
9051 async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
9054 let general = community.channels[0].id;
9055 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
9056 for i in 0..n {
9057 let at = base_secs + i as u64;
9058 let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
9059 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
9060 relay.publish(&wrap, &community.relays).await.unwrap();
9061 }
9062 }
9063
9064 #[tokio::test]
9065 async fn the_history_walk_pages_past_a_multi_page_burst() {
9066 let (_tmp, _guard, owner) = init_test_db();
9069 let relay = MemoryRelay::new();
9070 let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
9071 let general = community.channels[0].id;
9072 flood_general(&relay, &community, &owner, 120, 10_000).await;
9073
9074 let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
9075 assert_eq!(all.len(), 120, "the walk pages the whole burst");
9076 let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
9078 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
9079 assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
9080 let unique: std::collections::HashSet<&String> = contents.iter().collect();
9081 assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
9082
9083 let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
9085 assert_eq!(one.len(), 50, "fetch_channel is one newest page");
9086 assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
9087 }
9088
9089 #[tokio::test]
9090 async fn the_history_walk_stops_when_the_caller_is_caught_up() {
9091 let (_tmp, _guard, owner) = init_test_db();
9092 let relay = MemoryRelay::new();
9093 let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
9094 let general = community.channels[0].id;
9095 flood_general(&relay, &community, &owner, 120, 10_000).await;
9096
9097 let mut pages = 0usize;
9099 let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, crate::community::transport::Evidence::Quorum, |_| {
9100 pages += 1;
9101 false
9102 })
9103 .await
9104 .unwrap();
9105 assert_eq!(pages, 1, "the early stop is consulted once");
9106 assert_eq!(got.len(), 50, "only the newest page is fetched");
9107 assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
9108 }
9109
9110 #[tokio::test]
9111 async fn a_same_second_history_wall_terminates_instead_of_looping() {
9112 let (_tmp, _guard, owner) = init_test_db();
9116 let relay = MemoryRelay::new();
9117 let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
9118 let general = community.channels[0].id;
9119 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
9120 for i in 0..60usize {
9121 let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
9122 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
9123 relay.publish(&wrap, &community.relays).await.unwrap();
9124 }
9125 let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
9126 assert!(got.len() >= 25, "at least the relay page is read");
9127 assert!(got.len() <= 60, "sane bound");
9128 }
9130
9131 #[tokio::test]
9132 async fn a_grant_revoke_survives_a_withholding_relay() {
9133 let (_tmp, _guard, owner) = init_test_db();
9136 let relay = MemoryRelay::new();
9137 let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
9138 let admin = Keys::generate();
9139 let rid = "d4".repeat(32);
9140 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
9141 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
9142 let session = SessionGuard::capture();
9143 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();
9146
9147 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
9149 let mut stale = community.clone();
9150 stale.relays = vec!["wss://stale".into()];
9151 let floors = load_floors(&community);
9152 let editions = fetch_control(&relay, &stale).await;
9153 let authority = fold_authority(&stale, &editions, &floors);
9154 assert!(
9155 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
9156 "the persisted grant floor refuses the rolled-back (re-granted) view"
9157 );
9158 }
9159
9160 fn load_floors(community: &CommunityV2) -> Floors {
9162 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9163 crate::db::community::get_all_edition_heads_full(&cid_hex)
9164 .unwrap_or_default()
9165 .into_iter()
9166 .filter(|(_, f)| f.0 == community.root_epoch.0)
9167 .map(|(e, f)| (e, (f.1, f.2, f.3)))
9168 .collect()
9169 }
9170
9171 async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
9173 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9174 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9175 relay
9176 .fetch(&q, &community.relays)
9177 .await
9178 .unwrap_or_default()
9179 .iter()
9180 .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
9181 .collect()
9182 }
9183
9184 #[tokio::test]
9185 async fn follow_control_is_a_noop_on_a_freshly_created_community() {
9186 let (_tmp, _guard, _owner) = init_test_db();
9187 let relay = MemoryRelay::new();
9188 let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
9189 let session = SessionGuard::capture();
9190 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
9192 }
9193
9194 #[tokio::test]
9195 async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
9196 let (_tmp, _guard, owner) = init_test_db();
9197 let relay = MemoryRelay::new();
9198 let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
9199 let new_id = ChannelId([0x5a; 32]);
9200 publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
9201
9202 let session = SessionGuard::capture();
9203 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
9204 assert_eq!(updated.channels.len(), 2);
9205 let added = updated.channel(&new_id).expect("the new channel folded in");
9206 assert_eq!(added.name, "announcements");
9207 assert!(!added.private);
9208 assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
9209
9210 let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
9212 let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
9213 assert!(authors.contains(&addr), "the added channel joins the live subscription");
9214
9215 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9217 assert!(reloaded.channel(&new_id).is_some());
9218 }
9219
9220 #[tokio::test]
9221 async fn follow_control_renames_the_community_and_an_existing_channel() {
9222 let (_tmp, _guard, owner) = init_test_db();
9223 let relay = MemoryRelay::new();
9224 let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
9225 let general = community.channels[0].id;
9226 publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
9228 publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
9229
9230 let session = SessionGuard::capture();
9231 let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
9232 assert_eq!(updated.name, "New Name");
9233 assert_eq!(updated.channel(&general).unwrap().name, "lobby");
9234 assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
9235 }
9236
9237 #[tokio::test]
9238 async fn follow_control_deletes_a_channel() {
9239 let (_tmp, _guard, owner) = init_test_db();
9240 let relay = MemoryRelay::new();
9241 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
9242 let extra = ChannelId([0x77; 32]);
9243 let session = SessionGuard::capture();
9244
9245 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
9247 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
9248 assert!(with_extra.channel(&extra).is_some());
9249
9250 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
9252 let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
9253 assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
9254 assert_eq!(updated.channels.len(), 1, "only #general remains");
9255 }
9256
9257 async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
9261 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9262 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9263 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
9264 for w in &wraps {
9265 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
9266 if ed.version <= max_version {
9267 relay.inject(w, &[stale_relay.to_string()]);
9268 }
9269 }
9270 }
9271 }
9272
9273 #[tokio::test]
9274 async fn a_withholding_relay_cannot_roll_back_a_rename() {
9275 let (_tmp, _guard, owner) = init_test_db();
9278 let relay = MemoryRelay::new();
9279 let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
9280 publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
9281
9282 let session = SessionGuard::capture();
9283 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
9284 assert_eq!(updated.name, "Renamed");
9285
9286 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
9288 let mut stale_view = updated.clone();
9289 stale_view.relays = vec!["wss://stale".into()];
9290 assert!(
9291 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
9292 "a stale-only relay must not change the held view"
9293 );
9294 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9295 assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
9296 }
9297
9298 #[tokio::test]
9299 async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
9300 let (_tmp, _guard, owner) = init_test_db();
9301 let relay = MemoryRelay::new();
9302 let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
9303 let extra = ChannelId([0x44; 32]);
9304 let session = SessionGuard::capture();
9305
9306 publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
9309 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
9310
9311 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
9312 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
9313 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
9314 let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
9315 assert!(pruned.channel(&extra).is_none());
9316
9317 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
9319 let mut stale_view = pruned.clone();
9320 stale_view.relays = vec!["wss://stale".into()];
9321 assert!(
9322 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
9323 "the withheld delete must not resurrect the channel"
9324 );
9325 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9326 assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
9327 }
9328
9329 #[tokio::test]
9330 async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
9331 let (_tmp, _guard, owner) = init_test_db();
9335 let relay = MemoryRelay::new();
9336 let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
9337 let session = SessionGuard::capture();
9338 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
9339 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
9340 assert_eq!(updated.name, "Edited");
9341
9342 let mut refounded = updated.clone();
9345 refounded.root_epoch = crate::community::Epoch(1);
9346 crate::db::community::save_community_v2(&refounded).unwrap();
9347 publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
9348
9349 let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
9350 assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
9351 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9353 let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
9354 assert!(
9355 heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
9356 "the adopted head carries the fold's epoch + version"
9357 );
9358 }
9359
9360 #[tokio::test]
9361 async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
9362 let (_tmp, _guard, owner) = init_test_db();
9367 let relay = MemoryRelay::new();
9368 let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
9369 let session = SessionGuard::capture();
9370 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9371 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
9372
9373 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
9374 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
9375 assert_eq!(ours.name, "Ours");
9376
9377 let our_inner = {
9379 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9380 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
9381 wraps
9382 .iter()
9383 .find_map(|w| {
9384 control::open_control_edition(w, &group)
9385 .ok()
9386 .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
9387 .map(|(ed, _)| ed.inner_id)
9388 })
9389 .unwrap()
9390 };
9391
9392 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
9395 let content = serde_json::to_string(&meta).unwrap();
9396 let mut ts = 2_000u64;
9397 let fork_wrap = loop {
9398 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
9399 let inner = rumor.id.unwrap().to_bytes();
9400 if inner < our_inner {
9401 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
9402 }
9403 ts += 1;
9404 };
9405 relay.publish(&fork_wrap, &community.relays).await.unwrap();
9406
9407 let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
9408 assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
9409 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9410 let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
9411 assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
9412 }
9413
9414 #[tokio::test]
9415 async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
9416 let (_tmp, _guard, owner) = init_test_db();
9420 let relay = MemoryRelay::new();
9421 let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
9422 let session = SessionGuard::capture();
9423 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9424
9425 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
9426 let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
9427
9428 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
9430 let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
9431 let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
9432 let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
9433 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
9434 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
9435 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
9436 relay.publish(&w4, &community.relays).await.unwrap();
9437
9438 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
9439 assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
9440
9441 relay.publish(&w3, &community.relays).await.unwrap();
9442 let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
9443 assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
9444 }
9445
9446 #[tokio::test]
9447 async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
9448 let (_tmp, _guard, owner) = init_test_db();
9452 let relay = MemoryRelay::new();
9453 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
9454 let session = SessionGuard::capture();
9455 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9456
9457 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
9458 let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
9459 publish_community_meta(&relay, &base, &owner, "Three", 3).await; let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
9461
9462 let rogue = Keys::generate();
9465 for i in 0..(FOLLOW_PAGE as u64 - 1) {
9466 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
9467 let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
9468 relay.publish(&w, &community.relays).await.unwrap();
9469 }
9470 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
9473 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
9474 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
9475 relay.publish(&w4, &community.relays).await.unwrap();
9476
9477 let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
9478 assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
9479 }
9480
9481 #[tokio::test]
9482 async fn a_follow_after_delete_does_not_resurrect_the_community() {
9483 let (_tmp, _guard, owner) = init_test_db();
9486 let relay = MemoryRelay::new();
9487 let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
9488 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
9489 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9490 crate::db::community::delete_community(&cid_hex).unwrap();
9491
9492 let session = SessionGuard::capture();
9493 assert!(
9494 follow_control(&relay, &community, &session).await.unwrap().is_none(),
9495 "a follow racing a delete is a no-op"
9496 );
9497 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
9498 assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
9499 }
9500
9501 #[tokio::test]
9502 async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
9503 let (_tmp, _guard, owner) = init_test_db();
9506 let relay = MemoryRelay::new();
9507 let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
9508 let new_root = [0xB2; 32];
9509 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
9510 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9511 crate::db::community::delete_community(&cid_hex).unwrap();
9512
9513 let session = SessionGuard::capture();
9514 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
9515 assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
9516 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
9517 }
9518
9519 #[tokio::test]
9520 async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
9521 let (bed, owner, member) = TestBed::new();
9526 bed.swap_to(&owner);
9527 let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
9528 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9529 let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
9530
9531 let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
9533 let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
9534 let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
9535 let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
9536 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
9537 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);
9538 let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
9539 bed.relay.publish(&w3, &community.relays).await.unwrap();
9540
9541 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
9542 let bundle_json = serde_json::to_string(&bundle).unwrap();
9543 bed.swap_to(&member);
9544 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9545 assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
9546 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
9547 let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
9548 assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
9549 }
9550
9551 #[tokio::test]
9552 async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
9553 let (_tmp, _guard, owner) = init_test_db();
9557 let relay = MemoryRelay::new();
9558 let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
9559 let session = SessionGuard::capture();
9560 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9561 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
9562
9563 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
9564 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
9565 assert_eq!(ours.name, "Ours");
9566 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9567 let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
9568 let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
9569
9570 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
9573 let content = serde_json::to_string(&meta).unwrap();
9574 let mut ts = 5_000u64;
9575 let fork_wrap = loop {
9576 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
9577 if rumor.id.unwrap().to_bytes() > our_inner {
9578 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
9579 }
9580 ts += 1;
9581 };
9582 inject_stale_prefix(&relay, &community, 1, "wss://stale").await; relay.inject(&fork_wrap, &["wss://stale".to_string()]);
9584 let mut stale_view = ours.clone();
9585 stale_view.relays = vec!["wss://stale".into()];
9586
9587 assert!(
9588 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
9589 "a losing fork served without our floor edition changes nothing"
9590 );
9591 let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
9592 assert_eq!(held_after, held_before, "the floor row is untouched");
9593 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9594 assert_eq!(held.name, "Ours", "the held state is untouched");
9595 }
9596
9597 #[tokio::test]
9598 async fn follow_control_ignores_a_non_owner_edition() {
9599 let (_tmp, _guard, _owner) = init_test_db();
9603 let relay = MemoryRelay::new();
9604 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
9605 let rogue = Keys::generate();
9606 let rogue_id = ChannelId([0x99; 32]);
9607 publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
9608
9609 let session = SessionGuard::capture();
9610 assert!(
9611 follow_control(&relay, &community, &session).await.unwrap().is_none(),
9612 "a non-owner control edition is not folded"
9613 );
9614 }
9615
9616 #[tokio::test]
9617 async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
9618 let (_tmp, _guard, owner) = init_test_db();
9622 let relay = MemoryRelay::new();
9623 let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
9624 let priv_id = ChannelId([0x33; 32]);
9625 publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
9626
9627 let session = SessionGuard::capture();
9628 let updated = follow_control(&relay, &community, &session)
9629 .await
9630 .unwrap()
9631 .expect("the keyless record is a change");
9632 let ch = updated.channel(&priv_id).expect("the private channel is recorded");
9633 assert!(ch.private && ch.key.is_none(), "recorded keyless");
9634 assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
9635 assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
9636 assert!(
9637 fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
9638 "a keyless fetch returns empty (and never queries the root plane)"
9639 );
9640 assert!(
9641 send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
9642 "a keyless send refuses"
9643 );
9644 let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
9647 let rch = reloaded.channel(&priv_id).unwrap();
9648 assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
9649 let bundle = bundle_of(&reloaded, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
9653 assert!(
9654 !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
9655 "an ungrantable keyless channel stays out of invite bundles"
9656 );
9657 }
9658
9659 #[tokio::test]
9660 async fn a_link_bundle_never_carries_a_private_channel_key() {
9661 let (_tmp, _guard, _owner) = init_test_db();
9666 let relay = MemoryRelay::new();
9667 let community = create_community(&relay, "Leak", vec!["wss://r".into()], None).await.unwrap();
9668 create_private_channel(&relay, &community, "mods").await.unwrap();
9669 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9670 let priv_hex = held
9671 .channels
9672 .iter()
9673 .find(|c| c.private)
9674 .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
9675 .expect("the private channel is held WITH its key");
9676
9677 let link = bundle_of(&held, BundleAudience::Link, None, None, None);
9678 assert!(
9679 !link.channels.iter().any(|c| c.id == priv_hex),
9680 "a held private key must never ride a link bundle"
9681 );
9682 assert!(
9683 link.channels.iter().any(|c| c.id != priv_hex),
9684 "the public channels still ride it"
9685 );
9686
9687 let stranger = bundle_of(&held, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
9691 assert!(
9692 !stranger.channels.iter().any(|c| c.id == priv_hex),
9693 "an unentitled member gets no private key"
9694 );
9695 let mine = bundle_of(&held, BundleAudience::Member(me_pk().unwrap()), None, None, None);
9696 assert!(
9697 mine.channels.iter().any(|c| c.id == priv_hex),
9698 "the creator is entitled via the companion access role"
9699 );
9700 }
9701
9702 #[tokio::test]
9703 async fn a_private_channel_mints_its_access_role_and_entitlement_follows_the_grant() {
9704 let (_tmp, _guard, _owner) = init_test_db();
9707 let relay = MemoryRelay::new();
9708 let community = create_community(&relay, "Scoped", vec!["wss://r".into()], None).await.unwrap();
9709 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
9710 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
9711 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9712
9713 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
9714 let access = roster.channel_roles(&chan_hex);
9715 assert_eq!(access.len(), 1, "the channel minted exactly one access role");
9716 assert!(
9717 access[0].permissions == crate::community::roles::Permissions::empty(),
9718 "the access role confers READ access (key possession), never authority"
9719 );
9720 assert_eq!(access[0].name, "mods", "named for its channel");
9721
9722 let stranger = Keys::generate().public_key();
9724 let owner_hex = community.owner().unwrap().to_hex();
9725 assert!(!roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]));
9726
9727 let role_id = access[0].role_id.clone();
9730 assert!(
9731 roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, std::slice::from_ref(&role_id), &[]),
9732 "the grant overlay entitles before the fold catches up"
9733 );
9734
9735 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9736 grant_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
9737 let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
9738 assert!(
9739 after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
9740 "the grant landed in the local roster (the fold runs later)"
9741 );
9742 let vend = bundle_of(&held, BundleAudience::Member(stranger), None, None, None);
9743 assert!(
9744 vend.channels.iter().any(|c| c.id == chan_hex),
9745 "a now-entitled member's bundle carries the channel key"
9746 );
9747
9748 revoke_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
9749 let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
9750 assert!(
9751 !after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
9752 "the revoke dropped the access role"
9753 );
9754 let rotated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9755 assert_eq!(
9756 rotated.channel(&priv_id).unwrap().epoch,
9757 Epoch(2),
9758 "the revoke rotated the channel — a removal that doesn't rekey severs nobody"
9759 );
9760
9761 let access = crate::VectorCore.channel_access(&cid_hex, &chan_hex).unwrap();
9763 assert_eq!(access["private"], true);
9764 assert_eq!(access["readable"], true, "we minted it, so we hold its key");
9765 assert_eq!(access["roles"].as_array().unwrap().len(), 1, "one access role");
9766 let holders = access["members"].as_array().unwrap();
9767 let me_npub = {
9768 use nostr_sdk::prelude::ToBech32;
9769 me_pk().unwrap().to_bech32().unwrap()
9770 };
9771 assert_eq!(holders.len(), 1, "only the creator holds it — the revoked member is gone");
9772 assert_eq!(holders[0], serde_json::json!(me_npub), "and that holder is the creator");
9773 }
9774
9775 #[tokio::test]
9776 async fn a_vended_key_parks_until_the_fold_proves_the_grant_then_adopts() {
9777 let (bed, owner, member) = TestBed::new();
9781 bed.swap_to(&owner);
9782 let community = create_community(&bed.relay, "Vend", bed.relays.clone(), None).await.unwrap();
9783 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9784 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9785 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
9786 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9787 let real_key = held.channel(&priv_id).unwrap().key.unwrap();
9788 let owner_hex = community.owner().unwrap().to_hex();
9789
9790 bed.swap_to(&member);
9793 let me = member.keys.public_key().to_hex();
9794 let mut member_view = held.clone();
9797 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
9798 c.key = None;
9799 c.epoch = Epoch(0);
9800 }
9801
9802 let empty = crate::community::roles::CommunityRoles::default();
9805 assert!(matches!(
9806 judge_channel_key_vend(&member_view, &empty, &priv_id, Epoch(1), &owner_hex),
9807 VendVerdict::Park(_)
9808 ));
9809
9810 let mut public_view = member_view.clone();
9812 if let Some(c) = public_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
9813 c.private = false;
9814 }
9815 assert!(matches!(
9816 judge_channel_key_vend(&public_view, &empty, &priv_id, Epoch(1), &owner_hex),
9817 VendVerdict::Refuse(_)
9818 ));
9819
9820 assert!(matches!(
9822 judge_channel_key_vend(&member_view, &empty, &ChannelId([0x77; 32]), Epoch(1), &owner_hex),
9823 VendVerdict::Park(_)
9824 ));
9825
9826 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
9829 crate::db::community::set_community_roles(&cid_hex, &empty, 0).unwrap();
9830 crate::db::community::save_community_v2(&member_view).unwrap();
9831 let session = SessionGuard::capture();
9832 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9833 assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "unprovable vend adopts nothing");
9834 assert_eq!(
9835 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
9836 1,
9837 "and stays parked for the next fold"
9838 );
9839
9840 let access = crate::community::roles::Role {
9842 role_id: "44".repeat(32),
9843 name: "mods".into(),
9844 position: u32::MAX - 1,
9845 permissions: crate::community::roles::Permissions::empty(),
9846 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
9847 color: 0,
9848 };
9849 let folded = crate::community::roles::CommunityRoles {
9850 grants: vec![crate::community::roles::MemberGrant { member: me.clone(), role_ids: vec![access.role_id.clone()] }],
9851 roles: vec![access],
9852 };
9853 crate::db::community::set_community_roles(&cid_hex, &folded, 1).unwrap();
9854 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9855 let adopted = absorb_parked_channel_keys(&reloaded, &session);
9856 assert_eq!(adopted.len(), 1, "the re-judge adopts once the grant folds");
9857
9858 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9859 let ch = after.channel(&priv_id).unwrap();
9860 assert_eq!(ch.key, Some(real_key), "adopted the vended key");
9861 assert_eq!(ch.epoch, Epoch(1));
9862 assert!(
9863 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
9864 "and the park is discharged"
9865 );
9866 }
9867
9868 #[tokio::test]
9869 async fn a_vend_at_epoch_zero_is_adopted_onto_a_keyless_channel() {
9870 let (bed, owner, member) = TestBed::new();
9875 bed.swap_to(&owner);
9876 let community = create_community(&bed.relay, "EpochZero", bed.relays.clone(), None).await.unwrap();
9877 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9878 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9879 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
9880 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9881 let vended = [0x5a; 32];
9882 let owner_hex = community.owner().unwrap().to_hex();
9883
9884 bed.swap_to(&member);
9885 let mut member_view = held.clone();
9887 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
9888 c.key = None;
9889 c.epoch = Epoch(0);
9890 }
9891 crate::db::community::save_community_v2(&member_view).unwrap();
9892
9893 let access = crate::community::roles::Role {
9895 role_id: "77".repeat(32),
9896 name: "mods".into(),
9897 position: u32::MAX - 1,
9898 permissions: crate::community::roles::Permissions::empty(),
9899 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
9900 color: 0,
9901 };
9902 let roster = crate::community::roles::CommunityRoles {
9903 grants: vec![crate::community::roles::MemberGrant {
9904 member: member.keys.public_key().to_hex(),
9905 role_ids: vec![access.role_id.clone()],
9906 }],
9907 roles: vec![access],
9908 };
9909 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
9910 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &vended, &owner_hex).unwrap();
9911
9912 let session = SessionGuard::capture();
9913 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9914 let adopted = absorb_parked_channel_keys(&reloaded, &session);
9915 assert_eq!(adopted.len(), 1, "an epoch-0 vend onto a keyless channel is adopted");
9916
9917 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9918 let ch = after.channel(&priv_id).unwrap();
9919 assert_eq!(ch.key, Some(vended), "the key actually landed on the row");
9920 assert_eq!(ch.epoch, Epoch(0), "at the epoch the vendor named");
9921 assert!(
9922 !after.channel_read_coords(ch).is_empty(),
9923 "and the channel is readable — the whole point"
9924 );
9925 assert!(
9926 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
9927 "the park is discharged"
9928 );
9929 }
9930
9931 #[tokio::test]
9932 async fn a_wildly_ahead_vend_epoch_is_refused_not_seated() {
9933 let (bed, owner, member) = TestBed::new();
9939 bed.swap_to(&owner);
9940 let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
9941 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9942 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9943 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
9944 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9945 let owner_hex = community.owner().unwrap().to_hex();
9946
9947 bed.swap_to(&member);
9948 let mut member_view = held.clone();
9949 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
9950 c.key = None;
9951 c.epoch = Epoch(0);
9952 }
9953 crate::db::community::save_community_v2(&member_view).unwrap();
9954 let access = crate::community::roles::Role {
9955 role_id: "99".repeat(32),
9956 name: "mods".into(),
9957 position: u32::MAX - 1,
9958 permissions: crate::community::roles::Permissions::empty(),
9959 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
9960 color: 0,
9961 };
9962 let roster = crate::community::roles::CommunityRoles {
9963 grants: vec![crate::community::roles::MemberGrant {
9964 member: member.keys.public_key().to_hex(),
9965 role_ids: vec![access.role_id.clone()],
9966 }],
9967 roles: vec![access],
9968 };
9969 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
9970 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9971
9972 assert!(matches!(
9974 judge_channel_key_vend(&reloaded, &roster, &priv_id, Epoch(1 << 40), &owner_hex),
9975 VendVerdict::Refuse(_)
9976 ));
9977 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1 << 40, &[0xEE; 32], &owner_hex).unwrap();
9979 let session = SessionGuard::capture();
9980 assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "a poison epoch adopts nothing");
9981 assert!(
9982 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
9983 "and the row is discharged rather than parked forever"
9984 );
9985 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9987 assert_eq!(after.channel(&priv_id).unwrap().epoch, Epoch(0), "head never advanced");
9988 assert!(matches!(
9989 judge_channel_key_vend(&after, &roster, &priv_id, Epoch(1), &owner_hex),
9990 VendVerdict::Accept
9991 ));
9992 }
9993
9994 #[tokio::test]
9995 async fn a_channel_rename_lands_locally_without_waiting_for_the_fold() {
9996 let (_tmp, _guard, _owner) = init_test_db();
9999 let relay = MemoryRelay::new();
10000 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10001 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10002 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10003 let key_before = held.channel(&priv_id).unwrap().key;
10004
10005 let mut meta = held.channel(&priv_id).unwrap().metadata();
10006 meta.name = "staff".into();
10007 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10008
10009 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10010 let ch = after.channel(&priv_id).unwrap();
10011 assert_eq!(ch.name, "staff", "the rename is visible immediately");
10012 assert!(ch.private, "and privacy survives the edit");
10013 assert_eq!(ch.key, key_before, "as does the key — a rename is not a rotation");
10014 }
10015
10016 #[tokio::test]
10017 async fn a_channel_rename_carries_its_companion_access_role() {
10018 let (_tmp, _guard, _owner) = init_test_db();
10019 let relay = MemoryRelay::new();
10020 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10021 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10022 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10023 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10024
10025 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10026 let before = roster.channel_roles(&chan_hex);
10027 assert_eq!(before.len(), 1, "one companion role, minted at create");
10028 assert_eq!(before[0].name, "mods", "named after the channel it gates");
10029 let role_id = before[0].role_id.clone();
10030
10031 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10032 let mut meta = held.channel(&priv_id).unwrap().metadata();
10033 meta.name = "staff".into();
10034 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10035
10036 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10037 let after = roster.channel_roles(&chan_hex);
10038 assert_eq!(after.len(), 1, "renamed in place, never duplicated");
10039 assert_eq!(after[0].role_id, role_id, "a rename is a versioned edit of the same id");
10040 assert_eq!(after[0].name, "staff", "the access role followed the channel");
10041 assert_eq!(
10042 after[0].permissions,
10043 crate::community::roles::Permissions::empty(),
10044 "and still confers read access, never authority"
10045 );
10046 }
10047
10048 #[tokio::test]
10049 async fn a_customised_access_role_name_survives_a_channel_rename() {
10050 let (_tmp, _guard, _owner) = init_test_db();
10053 let relay = MemoryRelay::new();
10054 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10055 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10056 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10057 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10058
10059 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10060 let mut role = roster.channel_roles(&chan_hex)[0].clone();
10061 role.name = "Lab Insiders".into();
10062 set_role(&relay, &community, &role).await.unwrap();
10063 merge_local_roster(&cid_hex, Some(&role), None);
10064
10065 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10066 let mut meta = held.channel(&priv_id).unwrap().metadata();
10067 meta.name = "staff".into();
10068 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10069
10070 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10071 assert_eq!(
10072 roster.channel_roles(&chan_hex)[0].name,
10073 "Lab Insiders",
10074 "a deliberate name is left alone"
10075 );
10076 }
10077
10078 #[tokio::test]
10079 async fn a_squatted_park_row_cannot_suppress_the_genuine_vend() {
10080 let (bed, owner, member) = TestBed::new();
10086 bed.swap_to(&owner);
10087 let community = create_community(&bed.relay, "Squat", bed.relays.clone(), None).await.unwrap();
10088 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10089 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10090 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10091 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10092 let real_key = held.channel(&priv_id).unwrap().key.unwrap();
10093 let owner_hex = community.owner().unwrap().to_hex();
10094
10095 bed.swap_to(&member);
10096 let mut member_view = held.clone();
10097 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10098 c.key = None;
10099 c.epoch = Epoch(0);
10100 }
10101 crate::db::community::save_community_v2(&member_view).unwrap();
10102 let access = crate::community::roles::Role {
10103 role_id: "aa".repeat(32),
10104 name: "mods".into(),
10105 position: u32::MAX - 1,
10106 permissions: crate::community::roles::Permissions::empty(),
10107 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
10108 color: 0,
10109 };
10110 let roster = crate::community::roles::CommunityRoles {
10111 grants: vec![crate::community::roles::MemberGrant {
10112 member: member.keys.public_key().to_hex(),
10113 role_ids: vec![access.role_id.clone()],
10114 }],
10115 roles: vec![access],
10116 };
10117 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
10118
10119 let stranger = Keys::generate().public_key().to_hex();
10121 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 9, &[0xBA; 32], &stranger).unwrap();
10122 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
10124 assert_eq!(
10125 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
10126 2,
10127 "the squatter never displaces the genuine vend — both are candidates"
10128 );
10129
10130 let session = SessionGuard::capture();
10131 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10132 let adopted = absorb_parked_channel_keys(&reloaded, &session);
10133 assert_eq!(adopted.len(), 1, "exactly one adoption");
10134
10135 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10136 let ch = after.channel(&priv_id).unwrap();
10137 assert_eq!(ch.key, Some(real_key), "the OWNER's key won, not the squatter's");
10138 assert_eq!(ch.epoch, Epoch(1), "at the genuine epoch");
10139 assert!(
10140 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10141 "and every candidate for the channel is discharged"
10142 );
10143 }
10144
10145 #[tokio::test]
10146 async fn revoking_without_a_folded_access_role_refuses_instead_of_evicting_everyone() {
10147 let (_tmp, _guard, _owner) = init_test_db();
10152 let relay = MemoryRelay::new();
10153 let community = create_community(&relay, "NoRole", vec!["wss://r".into()], None).await.unwrap();
10154 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10155 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10156 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10157 let before = held.channel(&priv_id).unwrap().epoch;
10158
10159 crate::db::community::set_community_roles(&cid_hex, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
10163 let mut blind = held.clone();
10164 blind.relays = vec!["wss://empty".into()];
10165 let err = revoke_channel_access(&relay, &blind, &priv_id, &Keys::generate().public_key())
10166 .await
10167 .unwrap_err();
10168 assert!(err.contains("has not folded"), "refuses with a retryable reason: {err}");
10169
10170 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10171 assert_eq!(after.channel(&priv_id).unwrap().epoch, before, "and rotates nothing");
10172 }
10173
10174 async fn publish_base_rotation(
10181 relay: &MemoryRelay,
10182 community: &CommunityV2,
10183 rotator: &Keys,
10184 recipients: &[PublicKey],
10185 new_root: &[u8; 32],
10186 prev_key: &[u8; 32],
10187 ) {
10188 let new_epoch = Epoch(community.root_epoch.0 + 1);
10189 let prev_epoch = community.root_epoch;
10190 let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
10191 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
10192 let blobs: Vec<_> = recipients
10193 .iter()
10194 .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
10195 .collect();
10196 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();
10197 for e in &events {
10198 relay.publish(e, &community.relays).await.unwrap();
10199 }
10200 }
10201
10202 fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
10204 community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
10205 crate::db::community::save_community_v2(community).unwrap();
10206 }
10207
10208 #[tokio::test]
10209 async fn follow_rekeys_is_a_noop_without_rotations() {
10210 let (_tmp, _guard, _owner) = init_test_db();
10211 let relay = MemoryRelay::new();
10212 let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
10213 let session = SessionGuard::capture();
10214 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10215 assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
10216 }
10217
10218 #[tokio::test]
10219 async fn follow_rekeys_adopts_an_owner_base_rotation() {
10220 let (_tmp, _guard, owner) = init_test_db();
10221 let relay = MemoryRelay::new();
10222 let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
10223 let new_root = [0xB1; 32];
10224 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
10226
10227 let session = SessionGuard::capture();
10228 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
10229 assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
10230 assert_eq!(updated.community_root, new_root, "adopted the fresh root");
10231 let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
10233 let general = updated.channels[0].id;
10234 let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
10235 assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
10236 }
10237
10238 #[tokio::test]
10239 async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
10240 let (_tmp, _guard, owner) = init_test_db();
10241 let relay = MemoryRelay::new();
10242 let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
10243 let priv_id = ChannelId([0x33; 32]);
10244 add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
10245
10246 let new_key = [0x55; 32];
10248 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
10249 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
10250 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();
10251 let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None).unwrap();
10252 for e in &events {
10253 relay.publish(e, &community.relays).await.unwrap();
10254 }
10255
10256 let session = SessionGuard::capture();
10257 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
10258 let ch = updated.channel(&priv_id).unwrap();
10259 assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
10260 assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
10261 assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
10262 }
10263
10264 #[tokio::test]
10265 async fn follow_rekeys_ignores_a_non_owner_rotation() {
10266 let (_tmp, _guard, _owner) = init_test_db();
10269 let relay = MemoryRelay::new();
10270 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
10271 let rogue = Keys::generate();
10272 publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
10273
10274 let session = SessionGuard::capture();
10275 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10276 assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
10277 }
10278
10279 #[tokio::test]
10280 async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
10281 let (_tmp, _guard, owner) = init_test_db();
10284 let relay = MemoryRelay::new();
10285 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
10286 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
10288
10289 let session = SessionGuard::capture();
10290 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10291 assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
10292 }
10293
10294 #[tokio::test]
10295 async fn follow_rekeys_holds_on_an_incomplete_rotation() {
10296 let (_tmp, _guard, owner) = init_test_db();
10299 let relay = MemoryRelay::new();
10300 let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
10301 let new_epoch = Epoch(1);
10302 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
10303 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
10304 let other = Keys::generate();
10306 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
10307 let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None).unwrap();
10308 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
10309 relay.publish(&wrap, &community.relays).await.unwrap();
10310
10311 let session = SessionGuard::capture();
10312 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10313 assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
10314 }
10315
10316 #[tokio::test]
10317 async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
10318 let (bed, owner, member) = TestBed::new();
10322 bed.swap_to(&owner);
10323 let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
10324 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
10325
10326 bed.swap_to(&member);
10327 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10328 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
10329
10330 bed.swap_to(&owner);
10332 let stranger = Keys::generate();
10333 publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
10334
10335 bed.swap_to(&member);
10337 let session = SessionGuard::capture();
10338 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
10339 assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
10340 assert!(follow.updated.is_none(), "a removed member adopts nothing");
10341 }
10342
10343 #[tokio::test]
10344 async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
10345 let (_tmp, _guard, owner) = init_test_db();
10351 let relay = MemoryRelay::new();
10352 let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
10353 let root0 = community.community_root;
10354 let priv_id = ChannelId([0x33; 32]);
10355 let key1 = [0x44; 32];
10356 add_private_channel(&mut community, priv_id, key1, Epoch(1));
10357
10358 let key2 = [0x55; 32];
10361 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10362 let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
10363 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();
10364 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() {
10365 relay.publish(&e, &community.relays).await.unwrap();
10366 }
10367
10368 community.community_root = [0xB7; 32];
10372 community.root_epoch = Epoch(1);
10373 crate::db::community::save_community_v2(&community).unwrap();
10374
10375 let session = SessionGuard::capture();
10376 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
10377 let ch = updated.channel(&priv_id).unwrap();
10378 assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
10379 assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
10380 }
10381
10382 #[tokio::test]
10383 async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
10384 let (_tmp, _guard, owner) = init_test_db();
10389 let relay = MemoryRelay::new();
10390 let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
10391 let priv_id = ChannelId([0x66; 32]);
10392 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() });
10393 crate::db::community::save_community_v2(&community).unwrap();
10394 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10395 assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
10396
10397 let stranger = Keys::generate();
10399 let key1 = [0x71; 32];
10400 let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
10401 let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
10402 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();
10403 for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None).unwrap() {
10404 relay.publish(&e, &community.relays).await.unwrap();
10405 }
10406 let key2 = [0x72; 32];
10409 let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10410 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
10411 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();
10412 for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None).unwrap() {
10413 relay.publish(&e, &community.relays).await.unwrap();
10414 }
10415
10416 let session = SessionGuard::capture();
10419 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
10420 let ch = updated.channel(&priv_id).unwrap();
10421 assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
10422 assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
10423 }
10424
10425 #[tokio::test]
10426 async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
10427 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10431 let (_tmp, _guard, _owner) = init_test_db();
10432 let relay = MemoryRelay::new();
10433 let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
10434 let priv_id = ChannelId([0x88; 32]);
10435 let key1 = [0x91; 32];
10436 add_private_channel(&mut community, priv_id, key1, Epoch(1));
10437
10438 let admin = Keys::generate();
10440 let role = Role::admin("aa".repeat(32));
10441 let roster = CommunityRoles {
10442 roles: vec![role.clone()],
10443 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
10444 };
10445 seed_roster_with_heads(&community, &roster, 1_000);
10446
10447 let key2 = [0x92; 32];
10449 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10450 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
10451 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
10452 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
10453 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() {
10454 relay.publish(&e, &community.relays).await.unwrap();
10455 }
10456 let session = SessionGuard::capture();
10457 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
10458 assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
10459
10460 let rogue = Keys::generate();
10462 let key3 = [0x93; 32];
10463 let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
10464 let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
10465 let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
10466 for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None).unwrap() {
10467 relay.publish(&e, &updated.relays).await.unwrap();
10468 }
10469 let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
10470 assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
10471 }
10472
10473 #[tokio::test]
10474 async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
10475 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10480 let (bed, owner, member) = TestBed::new();
10481 bed.swap_to(&owner);
10482 let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
10483
10484 bed.swap_to(&member);
10487 let mut held = community.clone();
10488 let priv_id = ChannelId([0xAB; 32]);
10489 let key1 = [0xA1; 32];
10490 add_private_channel(&mut held, priv_id, key1, Epoch(1));
10491 let peer = Keys::generate();
10492 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10493 let role = Role::admin("bb".repeat(32));
10494 let roster = CommunityRoles {
10495 roles: vec![role.clone()],
10496 grants: vec![
10497 MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
10498 MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
10499 ],
10500 };
10501 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
10502
10503 let key2 = [0xA2; 32];
10505 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10506 let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
10507 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();
10508 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() {
10509 bed.relay.publish(&e, &held.relays).await.unwrap();
10510 }
10511 let session = SessionGuard::capture();
10512 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
10513 assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
10514 let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
10515 assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
10516
10517 let key3 = [0xA3; 32];
10519 let stranger = Keys::generate();
10520 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();
10521 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() {
10522 bed.relay.publish(&e, &held.relays).await.unwrap();
10523 }
10524 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
10525 let updated = follow.updated.expect("the owner's removal folds");
10526 assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
10527 }
10528
10529 #[tokio::test]
10530 async fn converting_a_public_channel_to_private_is_refused() {
10531 let (_tmp, _guard, _owner) = init_test_db();
10534 let relay = MemoryRelay::new();
10535 let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
10536 let general = community.channels[0].id;
10537 let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
10538 let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
10539 assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
10540 let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
10542 edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
10543 }
10544
10545 async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
10548 let doc = super::super::list::CommunityList {
10549 entries: vec![],
10550 tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
10551 extra: Default::default(),
10552 };
10553 let event = super::super::list::build_list_event(me, &doc).unwrap();
10554 relay.publish(&event, relays).await.unwrap();
10555 }
10556
10557 #[tokio::test]
10558 async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
10559 let (_tmp, _guard, me) = init_test_db();
10563 let relay = MemoryRelay::new();
10564 let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
10565 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
10566
10567 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
10569
10570 let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
10572 republish_community_list(&relay, Some(y.id())).await.unwrap();
10573
10574 let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
10576 assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
10577 assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
10578 }
10579
10580 #[tokio::test]
10581 async fn sync_tears_down_a_community_a_sibling_left() {
10582 let (_tmp, _guard, me) = init_test_db();
10585 let relay = MemoryRelay::new();
10586 let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
10587 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
10588 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
10589
10590 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
10591 sync_community_list(&relay, &x.relays).await.unwrap();
10592 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
10593 }
10594
10595 #[tokio::test]
10596 async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
10597 let (_tmp, _guard, me) = init_test_db();
10600 let relay = MemoryRelay::new();
10601 let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
10602 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
10603 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
10605 republish_community_list(&relay, Some(x.id())).await.unwrap();
10607 sync_community_list(&relay, &x.relays).await.unwrap();
10608 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
10609 }
10610
10611 #[tokio::test]
10612 async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
10613 let (_tmp, _guard, _me) = init_test_db();
10616 let good = MemoryRelay::new();
10617 let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
10618 assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
10619
10620 struct FetchErrors;
10622 #[async_trait::async_trait]
10623 impl Transport for FetchErrors {
10624 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
10625 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
10626 panic!("republish must NOT publish when the remote fetch failed");
10627 }
10628 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
10629 Ok(())
10630 }
10631 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
10632 Err("relay unreachable".to_string())
10633 }
10634 }
10635 republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
10637 }
10638
10639 #[tokio::test]
10640 async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
10641 let (_tmp, _guard, owner) = init_test_db();
10647 let relay = MemoryRelay::new();
10648 let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
10649
10650 let lurker = Keys::generate();
10652 let rid = "b1".repeat(32);
10653 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
10654 publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
10655
10656 let members = memberlist(&relay, &community).await.unwrap();
10658 assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
10659
10660 let banned_grantee = Keys::generate();
10662 publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
10663 set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
10664 let members = memberlist(&relay, &community).await.unwrap();
10665 assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
10666 assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
10667
10668 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
10670 assert_eq!(refounded.root_epoch, Epoch(1));
10671 let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
10672 let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
10673 let rotations = rekey::collect_rotations(&chunks);
10674 let lurker_x = lurker.public_key().to_bytes();
10675 let delivered = rotations.iter().any(|r| {
10676 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
10677 });
10678 assert!(delivered, "the Refounding delivered the new root to the granted lurker");
10679 }
10680
10681 #[tokio::test]
10682 async fn the_memberlist_pages_past_a_guestbook_flood() {
10683 let (_tmp, _guard, _owner) = init_test_db();
10687 let relay = MemoryRelay::new();
10688 let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
10689 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
10690
10691 let honest = Keys::generate();
10693 let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
10694 let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
10695 relay.publish(&w, &community.relays).await.unwrap();
10696 for i in 0..600u64 {
10697 let throwaway = Keys::generate();
10698 let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
10699 let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
10700 relay.publish(&w, &community.relays).await.unwrap();
10701 }
10702
10703 let members = memberlist(&relay, &community).await.unwrap();
10704 assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
10705 }
10706
10707 #[tokio::test]
10708 async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
10709 let (_tmp, _guard, owner) = init_test_db();
10714 let relay = MemoryRelay::new();
10715 let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
10716 let new_root = [0xD9; 32];
10717 let new_epoch = Epoch(1);
10718 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
10719
10720 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
10722
10723 let rogue = Keys::generate();
10726 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
10727 for i in 0..260u64 {
10728 let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
10729 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();
10730 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
10731 relay.publish(&wrap, &community.relays).await.unwrap();
10732 }
10733
10734 let session = SessionGuard::capture();
10735 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
10736 assert_eq!(updated.root_epoch, Epoch(1));
10737 assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
10738 }
10739
10740 #[tokio::test]
10741 async fn a_swap_during_create_private_channel_aborts_without_a_write() {
10742 let (bed, owner, _member) = TestBed::new();
10747 bed.swap_to(&owner);
10748 let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
10749 let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
10750
10751 let swap_relay = SwapMidPublish { inner: MemoryRelay::new() };
10753 let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
10754 assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
10755 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10756 assert_eq!(after.channels.len(), before, "no channel row was written");
10757 assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
10758 }
10759
10760 #[tokio::test]
10761 async fn an_uncited_admin_rotation_is_not_adopted() {
10762 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10767 let (_tmp, _guard, _owner) = init_test_db();
10768 let relay = MemoryRelay::new();
10769 let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
10770 let priv_id = ChannelId([0x8A; 32]);
10771 let key1 = [0x93; 32];
10772 add_private_channel(&mut community, priv_id, key1, Epoch(1));
10773
10774 let admin = Keys::generate();
10775 let role = Role::admin("cf".repeat(32));
10776 let roster = CommunityRoles {
10777 roles: vec![role.clone()],
10778 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
10779 };
10780 seed_roster_with_heads(&community, &roster, 1_000);
10781
10782 let key2 = [0x94; 32];
10783 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10784 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
10785 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
10786 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
10787 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None).unwrap() {
10789 relay.publish(&e, &community.relays).await.unwrap();
10790 }
10791
10792 let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
10793 assert!(out.updated.is_none(), "an uncited rotation is not adopted");
10794
10795 let cited = my_authority_citation(&community, &admin.public_key());
10798 assert!(cited.is_some(), "the seeded head yields a citation");
10799 let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
10800 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() {
10801 relay.publish(&e, &community.relays).await.unwrap();
10802 }
10803 let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
10804 assert!(out.updated.is_some(), "the cited rotation IS adopted");
10805 }
10806
10807 #[tokio::test]
10808 async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
10809 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10817 let (_tmp, _guard, _owner) = init_test_db();
10818 let relay = MemoryRelay::new();
10819 let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
10820 let priv_id = ChannelId([0xC0; 32]);
10821 let key1 = [0xC1; 32];
10822 add_private_channel(&mut community, priv_id, key1, Epoch(1));
10823
10824 let (a, b) = (Keys::generate(), Keys::generate());
10826 let role = Role::admin("ce".repeat(32));
10827 let roster = CommunityRoles {
10828 roles: vec![role.clone()],
10829 grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
10830 };
10831 seed_roster_with_heads(&community, &roster, 1_000);
10832
10833 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
10836 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10837 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
10838 let key_a = [0x0A; 32];
10839 let key_b = [0xFB; 32]; for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
10841 let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
10842 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() {
10843 relay.publish(&e, &community.relays).await.unwrap();
10844 }
10845 }
10846
10847 let session = SessionGuard::capture();
10848 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
10849 let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
10850 assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
10851
10852 let mut peer = community.clone();
10854 if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10855 c.key = Some(key1);
10856 c.epoch = Epoch(1);
10857 }
10858 let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
10860 assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
10861 }
10862
10863 #[tokio::test]
10864 async fn create_private_channel_refuses_a_member_without_manage_channels() {
10865 let (bed, owner, member) = TestBed::new();
10868 bed.swap_to(&owner);
10869 let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
10870 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
10871
10872 bed.swap_to(&member);
10873 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10874 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
10875 let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
10876 assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
10877 let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
10878 assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
10879 }
10880
10881 #[tokio::test]
10884 async fn accept_rejects_a_bundle_with_a_forged_community_root() {
10885 let (bed, owner, member) = TestBed::new();
10889 bed.swap_to(&owner);
10890 let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
10891
10892 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
10893 let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
10894 forged.community_root = fake.clone();
10895 for ch in &mut forged.channels {
10896 ch.key = fake.clone();
10897 }
10898 let attacker = Keys::generate();
10899 let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
10900 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
10901
10902 bed.swap_to(&member);
10903 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10904 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
10905 assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
10906 assert!(
10907 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
10908 "a rejected join persists nothing"
10909 );
10910 }
10911
10912 #[tokio::test]
10913 async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
10914 let (bed, owner, member) = TestBed::new();
10919 bed.swap_to(&owner);
10920 let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
10921 let general = community.channels[0].id;
10922
10923 let mut rotated = community.clone();
10924 rotated.community_root = [0x5A; 32];
10925 rotated.root_epoch = Epoch(1);
10926 let admin = Keys::generate();
10927 publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
10928 publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
10929
10930 bed.swap_to(&member);
10931 let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
10932 let session = SessionGuard::capture();
10933 let joined = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
10934 assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
10935 }
10936
10937 #[tokio::test]
10938 async fn only_an_actual_join_publishes_a_guestbook_join() {
10939 let (bed, owner, member) = TestBed::new();
10943 bed.swap_to(&owner);
10944 let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
10945
10946 let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
10947 async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
10948 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
10949 relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
10950 }
10951 let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await; bed.swap_to(&member);
10954 let bundle = bundle_of(&community, BundleAudience::Link, None, None, None);
10955 let session = SessionGuard::capture();
10956 accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
10957 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
10958
10959 accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
10960 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
10961
10962 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10963 crate::db::community::delete_community(&cid_hex).unwrap();
10964 accept_bundle(&bed.relay, &session, &bundle, None, false).await.unwrap();
10965 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
10966 }
10967
10968 #[tokio::test]
10969 async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
10970 let (bed, owner, member) = TestBed::new();
10973 bed.swap_to(&owner);
10974 let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
10975
10976 let mut rotated = community.clone();
10977 rotated.community_root = [0x5B; 32];
10978 rotated.root_epoch = Epoch(1);
10979 let attacker = Keys::generate();
10980 publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
10981
10982 bed.swap_to(&member);
10983 let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
10984 let session = SessionGuard::capture();
10985 let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
10986 assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
10987 }
10988
10989 #[tokio::test]
10990 async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
10991 let (bed, owner, member) = TestBed::new();
10994 bed.swap_to(&owner);
10995 let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
10996 let general = community.channels[0].id;
10997
10998 let mut fake = community.clone();
10999 fake.community_root = [0x5C; 32]; let admin = Keys::generate();
11001 publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
11002 publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
11003
11004 bed.swap_to(&member);
11005 let bundle = bundle_of(&fake, BundleAudience::Link, None, None, None);
11006 let session = SessionGuard::capture();
11007 let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
11008 assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
11009 }
11010
11011 #[tokio::test]
11012 async fn follow_control_heals_a_bundle_misclassified_public_channel() {
11013 let (_tmp, _guard, _owner) = init_test_db();
11017 let relay = MemoryRelay::new();
11018 let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
11019 let general = community.channels[0].id;
11020 let mut poisoned = community.clone();
11021 poisoned.channels[0].private = true;
11022 poisoned.channels[0].key = Some([0x66; 32]);
11023 crate::db::community::save_community_v2(&poisoned).unwrap();
11024
11025 let session = SessionGuard::capture();
11026 let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
11027 let ch = healed.channel(&general).unwrap();
11028 assert!(!ch.private, "the owner's public declaration overrides the bundle");
11029 assert_eq!(ch.key, None, "a healed public channel derives from the root");
11030 }
11031
11032 #[tokio::test]
11033 async fn a_deleted_channel_does_not_resurrect_on_reload() {
11034 let (_tmp, _guard, owner) = init_test_db();
11037 let relay = MemoryRelay::new();
11038 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
11039 let extra = ChannelId([0x77; 32]);
11040 let session = SessionGuard::capture();
11041 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
11042 let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
11043 assert!(with_extra.channel(&extra).is_some());
11044 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
11045 let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
11046 assert!(after.channel(&extra).is_none());
11047
11048 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11049 assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
11050 assert_eq!(reloaded.channels.len(), 1);
11051 }
11052
11053 #[tokio::test]
11054 async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
11055 let (_tmp, _guard, _owner) = init_test_db();
11059 let relay = MemoryRelay::new();
11060 let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
11061 let a_channel = a.channels[0].id;
11062 let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
11063 let b_channel = b.channels[0].id;
11064 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() });
11066
11067 crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
11068 let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
11070 assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
11071 assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
11072 let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
11074 assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
11075 assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
11076 }
11077
11078 struct CappedRelay {
11084 events: Vec<Event>,
11085 cap: usize,
11086 }
11087 #[async_trait::async_trait]
11088 impl Transport for CappedRelay {
11089 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
11090 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
11091 Ok(())
11092 }
11093 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
11094 Ok(())
11095 }
11096 async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
11097 let mut m: Vec<Event> = self
11098 .events
11099 .iter()
11100 .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
11101 .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
11102 .cloned()
11103 .collect();
11104 m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
11106 Ok(m)
11107 }
11108 }
11109
11110 #[tokio::test]
11111 async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
11112 let (_tmp, _guard, _owner) = init_test_db();
11119 let memory = MemoryRelay::new();
11120 let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
11121 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
11122
11123 let rogue = Keys::generate();
11124 let mut events: Vec<Event> = Vec::new();
11125 for i in 0..FOLLOW_PAGE {
11126 let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
11127 let rumor = control::build_edition_rumor(
11128 rogue.public_key(),
11129 vsk::CHANNEL_METADATA,
11130 &[0xAB; 32],
11131 1,
11132 None,
11133 &content,
11134 9_000,
11135 None,
11136 );
11137 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
11138 events.push(w);
11139 }
11140 let relay = CappedRelay { events, cap: FOLLOW_PAGE };
11141
11142 let err = refound_community(&relay, &community, &[])
11143 .await
11144 .expect_err("a plane that can't be read whole must never be compacted");
11145 assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
11146 }
11147
11148 #[tokio::test]
11149 async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
11150 let (_tmp, _guard, owner) = init_test_db();
11155 let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
11156 let g = control::genesis(&owner, meta, 1_000).unwrap();
11157 let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
11158
11159 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
11160 let rogue = Keys::generate();
11161 let mut events: Vec<Event> = g.wraps.to_vec();
11162 for i in 0..250u64 {
11163 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
11164 let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
11165 events.push(wrap);
11166 }
11167 let relay = CappedRelay { events, cap: 100 };
11169 let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
11170 assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
11171 }
11172
11173 #[tokio::test]
11174 async fn accept_parked_invite_joins_from_the_stored_bundle() {
11175 let (bed, owner, member) = TestBed::new();
11178 bed.swap_to(&owner);
11179 let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
11180 let general = community.channels[0].id;
11181 send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
11182 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
11183 let bundle_json = serde_json::to_string(&bundle).unwrap();
11184 let inviter_hex = owner.keys.public_key().to_hex();
11185
11186 bed.swap_to(&member);
11187 let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
11188 assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
11189 assert!(joined.identity.verify());
11190 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
11191 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
11194 assert!(
11195 crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
11196 "the joiner's control floor is seeded from the join-time fold"
11197 );
11198
11199 bed.swap_to(&owner);
11201 let members = memberlist(&bed.relay, &community).await.unwrap();
11202 assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
11203 }
11204
11205 #[tokio::test]
11206 async fn accept_parked_invite_rejects_a_forged_root() {
11207 let (_tmp, _guard, _owner) = init_test_db();
11211 let relay = MemoryRelay::new();
11212 let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
11213 let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
11214 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
11215 forged.community_root = fake.clone();
11216 for ch in &mut forged.channels {
11217 ch.key = fake.clone();
11218 }
11219 let bundle_json = serde_json::to_string(&forged).unwrap();
11220
11221 let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
11222 assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
11223 }
11224
11225 #[test]
11226 fn v2_and_v1_bundles_are_distinguishable_by_parse() {
11227 let owner = Keys::generate();
11231 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
11232 let hex = crate::simd::hex::bytes_to_hex_32;
11233 let v2 = invite::CommunityInvite {
11234 community_id: hex(&identity.community_id.0),
11235 owner: hex(&identity.owner_xonly),
11236 owner_salt: hex(&identity.owner_salt),
11237 community_root: hex(&[0x11; 32]),
11238 root_epoch: 0,
11239 channels: vec![],
11240 relays: vec!["wss://r".into()],
11241 name: "V2".into(),
11242 icon: None,
11243 expires_at: None,
11244 creator_npub: None,
11245 label: None,
11246 extra: Default::default(),
11247 };
11248 let v2_json = serde_json::to_string(&v2).unwrap();
11249 assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
11250 let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
11251 assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
11252 }
11253
11254 #[tokio::test]
11255 async fn verify_rejects_a_cross_community_owner_edition_replay() {
11256 let (_tmp, _guard, owner) = init_test_db();
11262
11263 let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
11265 let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
11266 let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
11267
11268 let t_identity = control::CommunityIdentity::mint(&owner.public_key());
11270 let fake_root = [0xEE; 32];
11271 let t = CommunityV2 {
11272 identity: t_identity,
11273 community_root: fake_root,
11274 root_epoch: Epoch(0),
11275 name: "T".into(),
11276 description: None,
11277 icon: None,
11278 banner: None,
11279 meta_custom: None,
11280 meta_extra: Default::default(),
11281 relays: vec!["wss://r".into()],
11282 channels: vec![],
11283 dissolved: false,
11284 created_at_ms: 0,
11285 };
11286 let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
11289 let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
11290 let relay = MemoryRelay::new();
11291 relay.publish(&replayed, &t.relays).await.unwrap();
11292
11293 let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
11294 assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
11295 }
11296
11297 #[tokio::test]
11305 #[ignore = "hits a real relay over the network"]
11306 async fn live_smoke_create_send_fetch_on_a_real_relay() {
11307 use crate::community::transport::LiveTransport;
11308 use nostr_sdk::prelude::ToBech32;
11309
11310 let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
11311 let relays = vec![relay.clone()];
11312
11313 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
11315 crate::db::close_database();
11316 crate::db::clear_id_caches();
11317 let tmp = tempfile::tempdir().unwrap();
11318 let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
11321 Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
11322 Err(_) => Keys::generate(),
11323 };
11324 let npub = keys.public_key().to_bech32().unwrap();
11325 if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
11328 println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
11329 }
11330 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
11331 crate::db::set_app_data_dir(tmp.path().to_path_buf());
11332 crate::db::set_current_account(npub.clone()).unwrap();
11333 crate::db::init_database(&npub).unwrap();
11334 crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
11335 crate::state::set_my_public_key(keys.public_key());
11336 println!("[smoke] throwaway identity {npub}");
11337
11338 let client = crate::nostr_client_builder().build();
11340 client.add_managed_relay(relay.as_str()).await.ok();
11341 client.connect().await;
11342 crate::state::set_nostr_client(client);
11343 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
11344
11345 let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
11347 let general = community.channels[0].id;
11348 println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
11349
11350 let text = "hello from a Vector Concord v2 live smoke test";
11351 let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
11352 println!("[smoke] sent message {sent_id}");
11353
11354 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
11356
11357 let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
11358 let texts: Vec<String> = page
11359 .iter()
11360 .filter_map(|f| match &f.event {
11361 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
11362 _ => None,
11363 })
11364 .collect();
11365 println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
11366 assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
11367
11368 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
11370 println!("[smoke] invite link: {}", link.url);
11371 println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
11372 }
11373
11374 #[tokio::test]
11375 async fn chat_ops_react_edit_delete_round_trip() {
11376 let (bed, owner, _member) = TestBed::new();
11377 bed.swap_to(&owner);
11378 let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
11379 let general = community.channels[0].id;
11380 let me_hex = owner.keys.public_key().to_hex();
11381
11382 let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
11383 send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
11384 .await
11385 .unwrap();
11386 send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
11387 send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
11388
11389 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11390 let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
11391 let mut saw = (false, false, false);
11392 for f in &page {
11393 match &f.event {
11394 ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
11395 assert_eq!(emoji, ":fire:");
11396 assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
11397 saw.0 = true;
11398 }
11399 ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
11400 assert_eq!(new_content, "edited");
11401 saw.1 = true;
11402 }
11403 ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
11404 _ => {}
11405 }
11406 }
11407 assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
11408 }
11409
11410 #[tokio::test]
11411 async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
11412 let (bed, owner, _member) = TestBed::new();
11413 bed.swap_to(&owner);
11414 let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
11415 let general = community.channels[0].id;
11416 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
11417
11418 let mut sub = bed.relay.subscribe(Query {
11420 kinds: vec![stream::KIND_WRAP_EPHEMERAL],
11421 authors: vec![group.pk_hex()],
11422 ..Default::default()
11423 });
11424 send_typing(&bed.relay, &community, &general).await.unwrap();
11425 let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
11426 let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
11427 Ok(ChatEvent::Typing { opened }) => opened,
11428 other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
11429 };
11430
11431 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11434 assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
11435
11436 assert!(
11438 crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
11439 "ephemeral sends must not retain scrub keys"
11440 );
11441 }
11442
11443 #[tokio::test]
11444 async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
11445 let (bed, owner, _member) = TestBed::new();
11446 bed.swap_to(&owner);
11447 let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
11448 let general = community.channels[0].id;
11449 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
11450
11451 let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
11452
11453 let (keys, outer_hex, relays) =
11456 crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
11457 assert_eq!(relays, community.relays);
11458 let wrap_query = Query {
11459 kinds: vec![stream::KIND_WRAP],
11460 authors: vec![group.pk_hex()],
11461 ..Default::default()
11462 };
11463 let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
11464 let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
11465 assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
11466
11467 let me_hex = owner.keys.public_key().to_hex();
11469 let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
11470 .await
11471 .unwrap();
11472 assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
11473
11474 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
11477 assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
11478 let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
11479 assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
11480 }
11481
11482 #[tokio::test]
11483 async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
11484 let (bed, owner, _member) = TestBed::new();
11485 bed.swap_to(&owner);
11486 let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
11487 let general = community.channels[0].id;
11488 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
11489
11490 let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
11493 crate::db::community::delete_message_key(&id).unwrap();
11494 assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
11495
11496 let mkeys = Keys::generate();
11498 let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
11499 let foreign_id = rumor.id.unwrap().to_hex();
11500 let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
11501 bed.relay.publish(&fw, &community.relays).await.unwrap();
11502
11503 fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11505 let (keys, _outer, relays) =
11506 crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
11507 assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
11508 assert_eq!(relays, community.relays);
11509
11510 assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
11512
11513 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
11515 let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11516 assert!(
11517 !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
11518 "healed message scrubbed from the relay"
11519 );
11520 }
11521
11522 #[tokio::test]
11523 async fn send_chat_message_threads_the_reply_and_extra_tags() {
11524 let (bed, owner, _member) = TestBed::new();
11525 bed.swap_to(&owner);
11526 let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
11527 let general = community.channels[0].id;
11528 let me_hex = owner.keys.public_key().to_hex();
11529
11530 let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
11531 let imeta = nostr_sdk::prelude::Tag::custom(
11532 "imeta",
11533 ["url https://e/blob".to_string(), "m image/png".to_string()],
11534 );
11535 let child_id = send_chat_message(
11536 &bed.relay, &community, &general, "child",
11537 Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
11538 )
11539 .await
11540 .unwrap();
11541
11542 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11543 let child = page
11544 .iter()
11545 .find_map(|f| match &f.event {
11546 ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
11547 _ => None,
11548 })
11549 .expect("the reply message round-trips");
11550 let reply = child.1.as_ref().expect("the reply reference is carried");
11551 assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
11552 assert_eq!(reply.author, Some(owner.keys.public_key()));
11553 assert!(
11554 child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
11555 "the imeta attachment tag rides the rumor verbatim"
11556 );
11557 }
11558
11559 #[tokio::test]
11560 async fn a_kick_needs_kick_authority_and_removes_the_target() {
11561 let (bed, owner, member) = TestBed::new();
11562 bed.swap_to(&owner);
11563 let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
11564
11565 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
11567 let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
11568 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
11569 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
11570 let before = memberlist(&bed.relay, &community).await.unwrap();
11571 assert!(before.contains(&member.keys.public_key()), "the join lands first");
11572
11573 bed.swap_to(&member);
11575 let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
11576 assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
11577
11578 bed.swap_to(&owner);
11580 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
11581 let after = memberlist(&bed.relay, &community).await.unwrap();
11582 assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
11583 assert!(after.contains(&owner.keys.public_key()), "the owner remains");
11584 }
11585
11586 #[tokio::test]
11587 async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
11588 let (bed, owner, member) = TestBed::new();
11593 bed.swap_to(&owner);
11594 let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
11595 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11596 let (o, m) = (owner.keys.public_key(), member.keys.public_key());
11597 let join = |at: u64, id: u8| guestbook::GuestbookEvent {
11598 rumor_id: [id; 32],
11599 entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
11600 };
11601 let kick = |at: u64, id: u8| guestbook::GuestbookEvent {
11602 rumor_id: [id; 32],
11603 entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
11604 };
11605
11606 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
11608 assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
11609
11610 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
11612 assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
11613
11614 crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
11616 assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
11617
11618 assert!(
11621 !stored_memberlist(&community).unwrap().contains(&m),
11622 "the memberlist excludes an un-caught-up member — why it can't gate a kick"
11623 );
11624 }
11625
11626 fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
11631 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11632 crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
11633 for g in &roster.grants {
11634 let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
11635 let eid = super::super::derive::grant_locator(community.id(), &m);
11636 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
11637 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
11638 }
11639 }
11640
11641 async fn publish_grant_citing(
11643 relay: &MemoryRelay,
11644 community: &CommunityV2,
11645 signer: &Keys,
11646 member: &PublicKey,
11647 role_ids: Vec<String>,
11648 version: u64,
11649 citation: Option<&crate::community::edition::AuthorityCitation>,
11650 ) {
11651 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
11652 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
11653 let prev = head_hash_on_relay(relay, community, &eid).await;
11654 let grant = MemberGrant { member: member.to_hex(), role_ids };
11655 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
11656 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
11657 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
11658 relay.publish(&wrap, &community.relays).await.unwrap();
11659 }
11660
11661 #[tokio::test]
11662 async fn an_uncited_admin_edition_is_not_folded_but_a_cited_one_is() {
11663 let (bed, owner, admin) = TestBed::new();
11668 bed.swap_to(&owner);
11669 let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
11670 let admin_pk = admin.keys.public_key();
11671 let rid = "c3".repeat(32);
11672 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
11673 publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
11674
11675 let low_rid = "c4".repeat(32);
11679 let mut low = admin_role(&low_rid, Permissions::admin().0);
11680 low.position = 5;
11681 publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
11682
11683 let bystander = Keys::generate().public_key();
11684 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
11685 let view = fetch_authority(&bed.relay, &community).await;
11686 assert!(
11687 !view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
11688 "an uncited non-owner edition is not folded"
11689 );
11690 assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant folds");
11692
11693 let _ = follow_control(&bed.relay, &community, &SessionGuard::capture()).await;
11696 let entity_id = crate::community::v2::derive::grant_locator(community.id(), &admin_pk.to_bytes());
11697 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11698 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
11699 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
11700 let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
11701 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid], 2, Some(&cite)).await;
11702
11703 let view = fetch_authority(&bed.relay, &community).await;
11704 assert!(
11705 view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
11706 "the same edition WITH its synced citation folds"
11707 );
11708 }
11709
11710 #[tokio::test]
11711 async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
11712 let (bed, owner, member) = TestBed::new();
11717 bed.swap_to(&owner);
11718 let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
11719 let member_pk = member.keys.public_key();
11720 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11721
11722 crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
11724 crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
11725
11726 let join = guestbook::GuestbookEvent {
11728 rumor_id: [9u8; 32],
11729 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
11730 };
11731 assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
11732 assert!(
11733 !stored_memberlist(&community).unwrap().contains(&member_pk),
11734 "while banned, the fold keeps them out"
11735 );
11736
11737 crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
11739 assert!(
11740 stored_memberlist(&community).unwrap().contains(&member_pk),
11741 "after the unban the raced Join makes them a member again"
11742 );
11743 }
11744
11745 #[tokio::test]
11746 async fn a_stale_root_admin_write_is_refused_not_misdirected() {
11747 let (bed, owner, member) = TestBed::new();
11753 bed.swap_to(&owner);
11754 let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
11755 let member_pk = member.keys.public_key();
11756
11757 set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
11758 let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
11759
11760 let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
11762 assert!(err.contains("re-founded"), "unban: {err}");
11763 let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
11765 assert!(err.contains("re-founded"), "invite: {err}");
11766 let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
11768 assert!(err.contains("re-founded"), "kick: {err}");
11769
11770 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11772 set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
11773 let view = fetch_authority(&bed.relay, &fresh).await;
11774 assert!(view.banned.is_empty(), "the retried unban actually unbans");
11775 }
11776
11777 #[tokio::test]
11778 async fn an_uncited_kick_from_an_admin_is_not_honored() {
11779 let (bed, owner, member) = TestBed::new();
11784 bed.swap_to(&owner);
11785 let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
11786 let admin = Keys::generate();
11787 let member_pk = member.keys.public_key();
11788 grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
11789
11790 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11791 let view = fetch_authority(&bed.relay, &community).await;
11792 crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
11793
11794 let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
11795 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
11796 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
11797 let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
11798
11799 let joined = guestbook::GuestbookEvent {
11800 rumor_id: [1u8; 32],
11801 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
11802 };
11803 let kick = |citation, id: u8, at| guestbook::GuestbookEvent {
11804 rumor_id: [id; 32],
11805 entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
11806 };
11807 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
11808 let empty_bans = std::collections::BTreeSet::new();
11809 let empty_marks = std::collections::BTreeMap::new();
11810 let fold = |evs: &[guestbook::GuestbookEvent]| {
11811 fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
11812 };
11813
11814 assert!(
11815 fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
11816 "an uncited kick from an admin is not honored"
11817 );
11818 assert!(
11819 !fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
11820 "the same kick WITH its synced citation removes them"
11821 );
11822 }
11823
11824 #[tokio::test]
11825 async fn kicking_an_admin_strips_their_roles_first() {
11826 let (bed, owner, member) = TestBed::new();
11830 bed.swap_to(&owner);
11831 let community = create_community(&bed.relay, "Compose", bed.relays.clone(), None).await.unwrap();
11832 let member_pk = member.keys.public_key();
11833 let member_hex = member_pk.to_hex();
11834 let owner_hex = owner.keys.public_key().to_hex();
11835
11836 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
11837 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
11838
11839 kick_member(&bed.relay, &community, &member_pk).await.unwrap();
11840
11841 let view = fetch_authority(&bed.relay, &community).await;
11842 assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
11843 assert!(
11844 !view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
11845 "a kicked admin holds no bit"
11846 );
11847 assert!(
11848 !memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
11849 "and the directive still removed them"
11850 );
11851 }
11852
11853 #[tokio::test]
11854 async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
11855 let (bed, owner, member) = TestBed::new();
11856 bed.swap_to(&owner);
11857 let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
11858 let member_pk = member.keys.public_key();
11859 let member_hex = member_pk.to_hex();
11860 let owner_hex = owner.keys.public_key().to_hex();
11861
11862 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
11863 let view = fetch_authority(&bed.relay, &community).await;
11864 assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
11865 assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
11866
11867 let second = Keys::generate().public_key();
11870 grant_admin(&bed.relay, &community, &second).await.unwrap();
11871 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
11872 let view = fetch_authority(&bed.relay, &community).await;
11873 assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
11874 assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
11875 let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
11876 assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
11877
11878 revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
11880 let view = fetch_authority(&bed.relay, &community).await;
11881 assert!(!view.roles.is_admin(&member_hex), "revoked");
11882 assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
11883 assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
11884 }
11885
11886 #[tokio::test]
11887 async fn follow_control_persists_the_roster_for_sync_local_reads() {
11888 let (bed, owner, member) = TestBed::new();
11889 bed.swap_to(&owner);
11890 let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
11891 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11892 let member_hex = member.keys.public_key().to_hex();
11893 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
11894
11895 let session = crate::state::SessionGuard::capture();
11897 follow_control(&bed.relay, &community, &session).await.unwrap();
11898 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11899 assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
11900
11901 let withholding = MemoryRelay::new();
11904 let _ = follow_control(&withholding, &community, &session).await;
11905 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11906 assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
11907
11908 revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
11910 follow_control(&bed.relay, &community, &session).await.unwrap();
11911 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11912 assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
11913 }
11914
11915 #[tokio::test]
11916 async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
11917 let (bed, owner, member) = TestBed::new();
11918 bed.swap_to(&owner);
11919 let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
11920
11921 bed.swap_to(&member);
11922 let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
11923 assert!(err.contains("owner"), "refused before any publish: {err}");
11924
11925 bed.swap_to(&owner);
11928 let view = fetch_authority(&bed.relay, &community).await;
11929 assert!(view.roles.roles.is_empty(), "no role edition landed");
11930 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
11931 let view = fetch_authority(&bed.relay, &community).await;
11932 assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
11933 }
11934
11935 #[tokio::test]
11936 async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
11937 let (bed, owner, member) = TestBed::new();
11938 bed.swap_to(&owner);
11939 let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
11940 let member_pk = member.keys.public_key();
11941
11942 let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
11945 set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
11946 grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
11947
11948 let withholding = MemoryRelay::new();
11951 let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
11952 assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
11953
11954 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
11956 let view = fetch_authority(&bed.relay, &community).await;
11957 let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
11958 assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
11959 assert!(grant.role_ids.contains(&mod_rid));
11960 }
11961
11962 #[tokio::test]
11963 async fn fetch_authority_reflects_a_granted_admin() {
11964 let (bed, owner, member) = TestBed::new();
11965 bed.swap_to(&owner);
11966 let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
11967 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
11968 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11969 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
11970
11971 let view = fetch_authority(&bed.relay, &community).await;
11972 let member_hex = member.keys.public_key().to_hex();
11973 assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
11974 assert!(
11975 view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
11976 "an ADMIN_ALL grant carries KICK"
11977 );
11978 assert!(view.banned.is_empty());
11979 }
11980}