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, 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 start_until: Option<u64>,
562 evidence: crate::community::transport::Evidence,
563 mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
564) -> Result<Vec<FetchedEvent>, String> {
565 let session = SessionGuard::capture();
568 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
569 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
574 let coords: Vec<([u8; 32], Epoch)> = if ch.private {
575 let Some(current) = ch.key else {
576 return Ok(Vec::new());
577 };
578 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
579 let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
580 if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
581 held.push((ch.epoch, current));
582 }
583 held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
586 } else {
587 let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
588 if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
589 roots.push((community.root_epoch, community.community_root));
590 }
591 roots.into_iter().map(|(ep, root)| (root, ep)).collect()
592 };
593 if coords.is_empty() {
594 return Ok(Vec::new());
595 }
596
597 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
598 let mut seen_rumors = std::collections::HashSet::new();
599 let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
600 let mut until: Option<u64> = start_until;
601 let mut oldest: Option<u64> = None;
602 for _ in 0..max_pages {
603 let mut wraps: Vec<Event> = Vec::new();
609 let mut wrap_ids: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
610 for (secret, epoch) in &coords {
611 let plane = channel_group_key(secret, channel_id, *epoch);
612 let q = Query {
613 kinds: vec![stream::KIND_WRAP],
614 authors: vec![plane.pk_hex()],
615 since,
616 until,
617 limit: Some(page),
618 evidence,
619 ..Default::default()
620 };
621 if let Ok(evs) = transport.fetch_plane(plane.keys(), &q, &community.relays).await {
622 for e in evs {
623 if wrap_ids.insert(e.id) {
624 wraps.push(e);
625 }
626 }
627 }
628 }
629 if wraps.is_empty() {
630 break;
631 }
632 let mut fresh = 0usize;
633 let mut page_events: Vec<FetchedEvent> = Vec::new();
634 for wrap in &wraps {
635 if !seen_wraps.insert(wrap.id) {
636 continue;
637 }
638 fresh += 1;
639 let at = wrap.created_at.as_secs();
640 if oldest.is_none_or(|o| at < o) {
641 oldest = Some(at);
642 }
643 for (secret, epoch) in &coords {
645 let group = channel_group_key(secret, channel_id, *epoch);
646 if wrap.pubkey != group.pk() {
647 continue;
648 }
649 if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
650 let id = event.opened().rumor_id;
651 if seen_rumors.insert(id) {
652 if session.is_valid() {
653 heal_own_wrap_key(&event, &group, &community.relays);
654 }
655 page_events.push(FetchedEvent { event, epoch: *epoch });
656 }
657 }
658 break;
659 }
660 }
661 if fresh == 0 {
662 if wraps.len() < page {
663 break; }
665 let Some(o) = oldest else { break };
669 if o == 0 {
670 break;
671 }
672 crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
673 until = Some(o - 1);
674 continue;
675 }
676 let stop = !page_events.is_empty() && !keep_paging(&page_events);
677 out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
678 if stop {
679 break; }
681 until = oldest; }
683 out.sort_by_key(|(ms, _)| *ms);
684 Ok(out.into_iter().map(|(_, e)| e).collect())
685}
686
687#[derive(Debug, Clone, Copy, PartialEq, Eq)]
696pub enum BundleAudience {
697 Link,
699 Member(PublicKey),
701}
702
703pub fn bundle_of(
709 community: &CommunityV2,
710 audience: BundleAudience,
711 creator: Option<PublicKey>,
712 expires_at_ms: Option<u64>,
713 label: Option<String>,
714) -> CommunityInvite {
715 bundle_of_with_overlay(community, audience, creator, expires_at_ms, label, &[], &[])
716}
717
718pub fn bundle_of_with_overlay(
722 community: &CommunityV2,
723 audience: BundleAudience,
724 creator: Option<PublicKey>,
725 expires_at_ms: Option<u64>,
726 label: Option<String>,
727 with: &[String],
728 without: &[String],
729) -> CommunityInvite {
730 let hex = crate::simd::hex::bytes_to_hex_32;
731 let cid_hex = hex(&community.identity.community_id.0);
732 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
733 let owner_hex = community.owner().ok().map(|o| o.to_hex());
734 let recipient_hex = match audience {
735 BundleAudience::Link => None,
736 BundleAudience::Member(pk) => Some(pk.to_hex()),
737 };
738 let channels = community
739 .vendable_channels(&roster, owner_hex.as_deref(), recipient_hex.as_deref(), with, without)
740 .into_iter()
741 .map(|c| invite::ChannelGrant {
742 id: hex(&c.id.0),
743 key: hex(&c.key.unwrap_or(community.community_root)),
744 epoch: c.epoch.0,
745 name: c.name.clone(),
746 })
747 .collect();
748 CommunityInvite {
749 community_id: hex(&community.identity.community_id.0),
750 owner: hex(&community.identity.owner_xonly),
751 owner_salt: hex(&community.identity.owner_salt),
752 community_root: hex(&community.community_root),
753 root_epoch: community.root_epoch.0,
754 channels,
755 relays: community.relays.clone(),
756 name: community.name.clone(),
757 icon: community.icon.clone(),
760 expires_at: expires_at_ms,
761 creator_npub: creator.map(|p| p.to_hex()),
762 label,
763 extra: Default::default(),
764 }
765}
766
767pub async fn send_direct_invite<T: Transport + ?Sized>(
773 transport: &T,
774 community: &CommunityV2,
775 recipient: &PublicKey,
776 expires_at_ms: Option<u64>,
777 label: Option<String>,
778) -> Result<Event, String> {
779 let session = SessionGuard::capture();
780 assert_current_root(community)?;
783 let signer = crate::signer::active_signer()?;
784 let inviter_pk = me_pk()?;
785 let bundle = bundle_of(community, BundleAudience::Member(*recipient), Some(inviter_pk), expires_at_ms, label);
786 let wrap = invite::build_direct_invite_signed(&signer, inviter_pk, recipient, &bundle).await.map_err(|e| e.to_string())?;
787 if !session.is_valid() {
788 return Err("account changed before sending invite".to_string());
789 }
790 transport.publish(&wrap, &community.relays).await?;
791 Ok(wrap)
792}
793
794pub struct MintedLink {
798 pub url: String,
799 pub bundle_event: Event,
800 pub link_signer: Keys,
801 pub token: [u8; super::derive::TOKEN_LEN],
802 pub expires_at_ms: Option<u64>,
807 pub label: Option<String>,
808}
809
810pub async fn mint_public_link<T: Transport + ?Sized>(
816 transport: &T,
817 community: &CommunityV2,
818 base: &str,
819 expires_at_ms: Option<u64>,
820 label: Option<String>,
821) -> Result<MintedLink, String> {
822 let session = SessionGuard::capture();
823 let mut token = [0u8; super::derive::TOKEN_LEN];
824 token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
825 let link_signer = Keys::generate();
826 let bundle = bundle_of(community, BundleAudience::Link, Some(me_pk()?), expires_at_ms, label.clone());
827 let bundle_key = super::derive::invite_bundle_key(&token);
828 let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
829 let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
830
831 if !session.is_valid() {
832 return Err("account changed before minting link".to_string());
833 }
834 transport.publish_durable(&bundle_event, &community.relays).await?;
835 let minted = MintedLink { url, bundle_event, link_signer, token, expires_at_ms, label: label.clone() };
836 let _ = record_minted_link(transport, community, &minted).await;
840 if session.is_valid() {
845 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
846 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
847 let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
848 }
849 Ok(minted)
850}
851
852async fn fetch_invite_list<T: Transport + ?Sized>(
861 transport: &T,
862 relays: &[String],
863) -> Result<Option<invite::InviteList>, String> {
864 let signer = crate::signer::active_signer()?;
865 let my_pk = me_pk()?;
866 let query = Query {
867 kinds: vec![super::kind::INVITE_LIST],
868 authors: vec![my_pk.to_hex()],
869 limit: Some(4),
870 evidence: crate::community::transport::Evidence::Full,
871 ..Default::default()
872 };
873 let events = transport.fetch(&query, relays).await?;
874 let mut best: Option<(u64, invite::InviteList)> = None;
875 for e in events {
876 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
877 let at = e.created_at.as_secs();
878 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
879 best = Some((at, l));
880 }
881 }
882 }
883 Ok(best.map(|(_, l)| l))
884}
885
886fn live_signers_for(list: &invite::InviteList, community_id_hex: &str, now_ms: u64) -> Vec<PublicKey> {
894 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
895 list.entries
896 .iter()
897 .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
898 .filter(|e| !e.expires_at.is_some_and(|exp| now_ms > exp))
899 .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
900 .collect()
901}
902
903async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
907 let my_pk = me_pk()?;
908 let eid = super::derive::invite_links_locator(community.id(), &my_pk.to_bytes());
909 let content = invite::build_registry_content(live_signers);
910 publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content).await?;
911 refresh_invite_registry_cache(transport, community, session).await;
915 Ok(())
916}
917
918async fn refresh_invite_registry_cache<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard) {
922 let Ok(owner) = community.owner() else { return };
923 let Some(editions) = fetch_control_plane_whole(transport, community).await else { return };
924 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
925 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
926 .unwrap_or_default()
927 .into_iter()
928 .filter(|(_, f)| f.0 == community.root_epoch.0)
929 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
930 .collect();
931 let authority = fold_authority(community, &editions, &floors);
932 let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
933 if session.is_valid() {
934 let _ = crate::db::community::set_community_invite_registry(&cid_hex, &flatten_link_sets(&sets));
935 let _ = crate::db::community::replace_invite_link_sets(&cid_hex, &sets);
936 }
937}
938
939async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
942 let session = SessionGuard::capture();
943 let signer = crate::signer::active_signer()?;
944 let my_pk = me_pk()?;
945 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
946 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
947 let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
952 if !list.entries.iter().any(|e| e.token == token_hex) {
953 list.entries.push(invite::InviteEntry {
954 token: token_hex,
955 signer_sk: minted.link_signer.secret_key().to_secret_hex(),
956 community_id: cid_hex.clone(),
957 url: minted.url.clone(),
958 label: minted.label.clone(),
959 created_at: now_ms() / 1000,
960 expires_at: minted.expires_at_ms,
961 extra: Default::default(),
962 });
963 }
964 if !session.is_valid() {
965 return Err("account changed during link record".to_string());
966 }
967 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
968 transport.publish(&event, &community.relays).await?;
969 let signers = live_signers_for(&list, &cid_hex, now_ms());
970 publish_invite_registry(transport, community, &session, &signers).await
971}
972
973pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
979 let session = SessionGuard::capture();
980 let signer = crate::signer::active_signer()?;
981 let my_pk = me_pk()?;
982 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
983 let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
984 let entry = list
985 .entries
986 .iter()
987 .find(|e| e.token == token_hex && e.community_id == cid_hex)
988 .cloned()
989 .ok_or("no such link in the invite list")?;
990 let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
992 let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
993 if !session.is_valid() {
994 return Err("account changed during revoke".to_string());
995 }
996 transport.publish_durable(&revocation, &community.relays).await?;
997 list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
999 list.entries.retain(|e| e.token != token_hex);
1000 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1001 transport.publish(&event, &community.relays).await?;
1002 let signers = live_signers_for(&list, &cid_hex, now_ms());
1003 publish_invite_registry(transport, community, &session, &signers).await?;
1004 if session.is_valid() {
1006 let _ = crate::db::community::delete_public_invite(token_hex);
1007 }
1008 Ok(())
1009}
1010
1011pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1016 let session = SessionGuard::capture();
1017 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1018 let signer = crate::signer::active_signer()?;
1023 let my_pk = me_pk()?;
1024 let query = Query {
1025 kinds: vec![super::kind::INVITE_LIST],
1026 authors: vec![my_pk.to_hex()],
1027 limit: Some(4),
1028 ..Default::default()
1029 };
1030 let events = transport.fetch(&query, &community.relays).await?;
1031 let mut best: Option<(u64, invite::InviteList)> = None;
1032 for e in events {
1033 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
1034 let at = e.created_at.as_secs();
1035 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
1036 best = Some((at, l));
1037 }
1038 }
1039 }
1040 let Some((_, list)) = best else {
1041 return Ok(());
1042 };
1043 let creator = my_pk;
1044 let now = now_ms();
1045 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1046 for entry in &list.entries {
1047 if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
1048 continue;
1049 }
1050 if entry.expires_at.is_some_and(|exp| now > exp) {
1053 continue;
1054 }
1055 let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
1056 let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
1057 let bundle = bundle_of(community, BundleAudience::Link, Some(creator), entry.expires_at, entry.label.clone());
1058 let bundle_key = super::derive::invite_bundle_key(&token);
1059 if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
1060 if !session.is_valid() {
1061 return Err("account changed during link refresh".to_string());
1062 }
1063 let _ = transport.publish_durable(&event, &community.relays).await;
1064 }
1065 }
1066 let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
1076 if !mine_here {
1077 return Ok(());
1078 }
1079 let signers = live_signers_for(&list, &cid_hex, now);
1080 if !session.is_valid() {
1081 return Err("account changed during link refresh".to_string());
1082 }
1083 let _ = publish_invite_registry(transport, community, &session, &signers).await;
1084 Ok(())
1085}
1086
1087pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1092 let Ok(owner) = community.owner() else { return false };
1093 let Some(editions) = fetch_control_plane_whole(transport, community).await else { return true };
1097 let cid = community.id();
1098 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1099 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1100 .unwrap_or_default()
1101 .into_iter()
1102 .filter(|(_, f)| f.0 == community.root_epoch.0)
1103 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1104 .collect();
1105 let authority = fold_authority(community, &editions, &floors);
1106 !live_invite_link_sets(cid, &owner.to_hex(), &editions, &authority, &floors).is_empty()
1107}
1108
1109async fn fetch_control_plane_whole<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Option<Vec<ParsedEdition>> {
1115 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1116 let mut editions: Vec<ParsedEdition> = Vec::new();
1117 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1118 let mut oldest: Option<u64> = None;
1119 let mut until: Option<u64> = None;
1120 for page in 0..COMPACT_MAX_PAGES {
1121 let query = Query {
1125 kinds: vec![stream::KIND_WRAP],
1126 authors: vec![control.pk_hex()],
1127 until,
1128 limit: Some(FOLLOW_PAGE),
1129 evidence: crate::community::transport::Evidence::Quorum,
1130 ..Default::default()
1131 };
1132 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return None };
1133 let mut fresh = 0usize;
1134 for w in &wraps {
1135 if !seen_wraps.insert(w.id) {
1136 continue;
1137 }
1138 fresh += 1;
1139 let at = w.created_at.as_secs();
1140 if oldest.is_none_or(|o| at < o) {
1141 oldest = Some(at);
1142 }
1143 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1144 editions.push(ed);
1145 }
1146 }
1147 if fresh == 0 {
1148 if wraps.len() >= FOLLOW_PAGE {
1149 return None; }
1151 return Some(editions);
1152 }
1153 until = oldest;
1154 if page + 1 == COMPACT_MAX_PAGES {
1155 return None;
1156 }
1157 }
1158 Some(editions)
1159}
1160
1161fn live_invite_link_sets(
1166 cid: &crate::community::CommunityId,
1167 owner_hex: &str,
1168 editions: &[ParsedEdition],
1169 authority: &AuthoritySet,
1170 floors: &Floors,
1171) -> Vec<crate::db::community::InviteLinkSetRow> {
1172 use crate::community::roles::Permissions;
1173 use std::collections::BTreeMap;
1174 let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
1175 for e in editions {
1176 if e.vsk == vsk::INVITE_LINKS {
1177 by_eid.entry(e.entity_id).or_default().push(e);
1178 }
1179 }
1180 let mut sets: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1181 for (eid, group) in &by_eid {
1182 let authed: Vec<&ParsedEdition> = group
1188 .iter()
1189 .copied()
1190 .filter(|p| {
1191 let author = p.author.to_hex();
1192 !authority.banned.contains(&author)
1194 && authority.roles.is_authorized(&author, Some(owner_hex), Permissions::CREATE_INVITE)
1195 && super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
1196 })
1197 .collect();
1198 if authed.is_empty() {
1199 continue;
1200 }
1201 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
1202 let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
1203 if let Ok(signers) = invite::parse_registry_content(&authed[hi].content) {
1204 if signers.is_empty() {
1205 continue; }
1207 sets.push(crate::db::community::InviteLinkSetRow {
1208 creator_hex: authed[hi].author.to_hex(),
1209 locators: signers.iter().map(|p| p.to_hex()).collect(),
1210 });
1211 }
1212 }
1213 sets
1214}
1215
1216fn flatten_link_sets(sets: &[crate::db::community::InviteLinkSetRow]) -> Vec<String> {
1218 let mut flat: Vec<String> = sets.iter().flat_map(|s| s.locators.iter().cloned()).collect();
1219 flat.sort();
1220 flat.dedup();
1221 flat
1222}
1223
1224async fn accept_bundle<T: Transport + ?Sized>(
1230 transport: &T,
1231 session: &SessionGuard,
1232 bundle: &CommunityInvite,
1233 invited_by: Option<PublicKey>,
1234 announce_join: bool,
1235) -> Result<CommunityV2, String> {
1236 let signer = crate::signer::active_signer()?;
1237 let my_pk = me_pk()?;
1238 let at_ms = now_ms();
1239 if bundle.expired(at_ms) {
1241 return Err("this invite has expired".to_string());
1242 }
1243 let community = CommunityV2::from_bundle(bundle, at_ms)?;
1245 let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
1248
1249 let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
1258 v.session.is_valid()
1259 && v.at.elapsed() < VERIFIED_PREVIEW_TTL
1260 && v.community_id == community.id().0
1261 && v.community_root == community.community_root
1262 });
1263 let (community, join_heads, join_banlist) = match handoff {
1264 Some(v) => {
1265 let mut c = v.folded;
1266 c.created_at_ms = at_ms;
1268 (c, v.heads, v.banned)
1269 }
1270 None => verify_owner_root_and_reconcile(transport, community).await?,
1271 };
1272
1273 if is_dissolved(transport, &community).await {
1275 return Err("this community has been dissolved".to_string());
1276 }
1277
1278 if join_banlist.contains(&my_pk.to_hex()) {
1283 return Err("you are banned from this community".to_string());
1284 }
1285
1286 if !session.is_valid() {
1290 return Err("account changed during join".to_string());
1291 }
1292 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1297 for h in &join_heads {
1298 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)?;
1299 }
1300 crate::db::community::save_community_v2(&community)?;
1301 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
1304 for ch in &community.channels {
1307 if let (true, Some(key)) = (ch.private, ch.key) {
1308 let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
1309 }
1310 }
1311
1312 if announce_join && !already_held {
1318 let attribution = invited_by
1319 .map(|p| p.to_hex())
1320 .or_else(|| bundle.creator_npub.clone())
1321 .zip(Some(bundle.label.clone().unwrap_or_default()));
1322 let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
1323 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1324 let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
1325 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1326 let _ = transport.publish(&join_wrap, &community.relays).await;
1327 }
1328 }
1329
1330 match republish_community_list(transport, Some(community.id())).await {
1334 Ok(true) => {}
1335 Ok(false) => republish_community_list_durable(Some(*community.id())),
1336 Err(e) => {
1337 crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
1338 republish_community_list_durable(Some(*community.id()));
1339 }
1340 }
1341 Ok(community)
1342}
1343
1344async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
1361 transport: &T,
1362 community: CommunityV2,
1363) -> Result<(CommunityV2, Vec<FoldedHead>, std::collections::BTreeSet<String>), String> {
1364 let owner = community.owner()?;
1365 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1366 let control_pk = control.pk_hex();
1367
1368 super::streamauth::prime(&community);
1375
1376 const PAGE: usize = 500;
1397 const MAX_PAGES: usize = 4;
1398 const FAR_FUTURE_SECS: u64 = 4_102_444_800; let mut editions: Vec<ParsedEdition> = Vec::new();
1400 let mut all_editions: Vec<ParsedEdition> = Vec::new();
1401 let mut found_genesis = false;
1402 let mut compacted_metadata = false;
1410 crate::log_debug!(
1411 "[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
1412 &control_pk[..12], community.root_epoch, community.relays
1413 );
1414 let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
1415 found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
1416 };
1417 for attempt in 0..2 {
1418 editions.clear();
1419 all_editions.clear();
1420 compacted_metadata = false;
1421 let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1422 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1423 for page_no in 0..MAX_PAGES {
1424 let query = Query {
1425 kinds: vec![stream::KIND_WRAP],
1426 authors: vec![control_pk.clone()],
1427 until,
1428 limit: Some(PAGE),
1429 evidence: crate::community::transport::Evidence::Full,
1430 ..Default::default()
1431 };
1432 let wraps = transport.fetch(&query, &community.relays).await?;
1433 crate::log_trace!(
1434 "[JoinVerify] attempt {} page {}: fetched {} wraps",
1435 attempt, page_no, wraps.len()
1436 );
1437 let mut oldest = u64::MAX;
1441 let mut fresh = 0usize;
1442 for w in &wraps {
1443 if !seen_wraps.insert(w.id) {
1444 continue;
1445 }
1446 fresh += 1;
1447 oldest = oldest.min(w.created_at.as_secs());
1448 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1449 crate::log_trace!(
1450 "[JoinVerify] edition vsk={} eid={} owner={} at={}",
1451 ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
1452 ed.author == owner, w.created_at.as_secs()
1453 );
1454 if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1455 if ed.author == owner {
1456 found_genesis = true;
1457 } else {
1458 compacted_metadata = true;
1459 }
1460 }
1461 if ed.author == owner {
1462 editions.push(ed.clone());
1463 }
1464 all_editions.push(ed);
1467 }
1468 }
1469 crate::log_debug!(
1470 "[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
1471 attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
1472 );
1473 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
1474 break; }
1476 until = Some(oldest);
1477 }
1478 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1479 break;
1480 }
1481 if attempt == 0 {
1482 if let Some(client) = crate::state::nostr_client() {
1489 super::streamauth::prime_auth(&client, &community.relays).await;
1490 }
1491 }
1492 }
1493 if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1494 return Err(
1495 "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"
1496 .to_string(),
1497 );
1498 }
1499 let empty_floors = Floors::new();
1508 let authority = AuthoritySet::owner_only();
1509 let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1510 let join_banlist = fold_authority(&community, &all_editions, &empty_floors).banned;
1515 Ok((fold.updated.unwrap_or(community), fold.heads, join_banlist))
1516}
1517
1518pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1522 let session = SessionGuard::capture();
1523 let signer = crate::signer::active_signer()?;
1524 let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
1525 accept_bundle(transport, &session, &bundle, Some(inviter), true).await
1526}
1527
1528pub async fn accept_parked_invite<T: Transport + ?Sized>(
1534 transport: &T,
1535 bundle_json: &str,
1536 inviter_hex: Option<&str>,
1537) -> Result<CommunityV2, String> {
1538 let session = SessionGuard::capture();
1539 let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1540 let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1541 accept_bundle(transport, &session, &bundle, invited_by, true).await
1542}
1543
1544pub async fn accept_migration_material<T: Transport + ?Sized>(
1550 transport: &T,
1551 jm: &super::list::JoinMaterial,
1552) -> Result<CommunityV2, String> {
1553 let session = SessionGuard::capture();
1554 let bundle = material_to_invite(jm);
1555 accept_bundle(transport, &session, &bundle, None, true).await
1556}
1557
1558pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1565 let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1566 let query = Query {
1571 kinds: vec![super::kind::INVITE_BUNDLE],
1572 authors: vec![parsed.link_signer.to_hex()],
1573 ..Default::default()
1574 };
1575 let relays = if parsed.bootstrap_relays.is_empty() {
1576 invite::stock_relays()
1577 } else {
1578 parsed.bootstrap_relays.clone()
1579 };
1580 let events = match transport.fetch(&query, &relays).await {
1585 Ok(evs) => evs,
1586 Err(_) => {
1587 wait_for_bootstrap_relay(&relays).await;
1588 transport.fetch(&query, &relays).await?
1589 }
1590 };
1591 let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1592
1593 let mut newest_live: Option<(u64, CommunityInvite)> = None;
1595 for event in &events {
1596 match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1597 Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1598 Ok(invite::BundleState::Live(bundle)) => {
1599 let at = event.created_at.as_secs();
1600 if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1601 newest_live = Some((at, *bundle));
1602 }
1603 }
1604 Err(_) => {} }
1606 }
1607 newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1608}
1609
1610async fn wait_for_bootstrap_relay(relays: &[String]) {
1614 let Some(client) = crate::state::nostr_client() else { return };
1615 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8);
1616 loop {
1617 for url in relays {
1618 if let Ok(Some(relay)) = client.relay(url).await {
1619 if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
1620 return;
1621 }
1622 }
1623 }
1624 if tokio::time::Instant::now() >= deadline {
1625 return;
1626 }
1627 tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1628 }
1629}
1630
1631struct VerifiedPreview {
1637 session: SessionGuard,
1638 at: std::time::Instant,
1639 community_id: [u8; 32],
1640 community_root: [u8; 32],
1641 folded: CommunityV2,
1642 heads: Vec<FoldedHead>,
1643 banned: std::collections::BTreeSet<String>,
1646}
1647static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
1648const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
1649
1650pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1657 let bundle = fetch_public_bundle(transport, url).await?;
1658 preview_bundle(transport, &bundle).await
1659}
1660
1661pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
1666 let community = CommunityV2::from_bundle(bundle, 0)?;
1667 match verify_owner_root_and_reconcile(transport, community.clone()).await {
1668 Ok((folded, heads, banned)) => {
1669 *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
1670 session: SessionGuard::capture(),
1671 at: std::time::Instant::now(),
1672 community_id: folded.id().0,
1673 community_root: folded.community_root,
1674 folded: folded.clone(),
1675 heads,
1676 banned,
1677 });
1678 Ok(folded)
1679 }
1680 Err(_) => Ok(community),
1681 }
1682}
1683
1684pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1686 let session = SessionGuard::capture();
1688 let bundle = fetch_public_bundle(transport, url).await?;
1689 if !session.is_valid() {
1690 return Err("account changed during join".to_string());
1691 }
1692 accept_bundle(transport, &session, &bundle, None, true).await
1693}
1694
1695pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1697 let session = SessionGuard::capture();
1698 let signer = crate::signer::active_signer()?;
1699 let my_pk = me_pk()?;
1700 let at_ms = now_ms();
1701 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1702 let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
1703 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1704 let _ = transport.publish(&wrap, &community.relays).await;
1705 }
1706 if !session.is_valid() {
1707 return Err("account changed during leave".to_string());
1708 }
1709 let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
1713 if !session.is_valid() {
1715 return Err("account changed during leave".to_string());
1716 }
1717 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1718 Ok(())
1719}
1720
1721pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1726 let session = SessionGuard::capture();
1727 assert_current_root(community)?;
1728 let signer = crate::signer::active_signer()?;
1729 let my_pk = me_pk()?;
1730 let authority = fetch_authority(transport, community).await;
1732 let owner_hex = community.owner()?.to_hex();
1733 if !authority.roles.can_act_on_member(
1734 &my_pk.to_hex(),
1735 Some(&owner_hex),
1736 &target.to_hex(),
1737 crate::community::roles::Permissions::KICK,
1738 ) {
1739 return Err("not authorized to kick this member".to_string());
1740 }
1741 let target_hex = target.to_hex();
1753 let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
1754 let may_strip = authority.roles.can_act_on_member(
1755 &my_pk.to_hex(),
1756 Some(&owner_hex),
1757 &target_hex,
1758 crate::community::roles::Permissions::MANAGE_ROLES,
1759 );
1760 if holds_roles && may_strip {
1761 grant_roles(transport, community, target, Vec::new())
1762 .await
1763 .map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
1764 if !session.is_valid() {
1765 return Err("account changed during kick".to_string());
1766 }
1767 }
1768 let at_ms = now_ms();
1769 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1770 let citation = required_authority_citation(community, &my_pk)?;
1773 let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
1774 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
1775 .map_err(|e| e.to_string())?;
1776 if !session.is_valid() {
1777 return Err("account changed before send".to_string());
1778 }
1779 transport.publish(&wrap, &community.relays).await?;
1780 Ok(())
1781}
1782
1783pub struct AuthorityView {
1789 pub roles: crate::community::roles::CommunityRoles,
1790 pub banned: std::collections::BTreeSet<String>,
1791 pub gapped: bool,
1793 pub floored: std::collections::BTreeSet<String>,
1795 pub head_entities: std::collections::BTreeSet<String>,
1797 pub banned_at: std::collections::BTreeMap<String, u64>,
1799}
1800
1801pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1807 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1808 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1809 .unwrap_or_default()
1810 .into_iter()
1811 .filter(|(_, f)| f.0 == community.root_epoch.0)
1812 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1813 .collect();
1814 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1815
1816 let mut editions: Vec<ParsedEdition> = Vec::new();
1817 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1818 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1819 let mut oldest: Option<u64> = None;
1820 let mut until: Option<u64> = None;
1821 let mut a = fold_authority(community, &[], &floors);
1826 for _ in 0..FOLLOW_MAX_PAGES {
1827 let query = Query {
1831 kinds: vec![stream::KIND_WRAP],
1832 authors: vec![control.pk_hex()],
1833 until,
1834 limit: Some(FOLLOW_PAGE),
1835 evidence: crate::community::transport::Evidence::Quorum,
1836 ..Default::default()
1837 };
1838 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
1839 let mut fresh = 0usize;
1840 for w in &wraps {
1841 if !seen_wraps.insert(w.id) {
1842 continue;
1843 }
1844 fresh += 1;
1845 let at = w.created_at.as_secs();
1846 if oldest.is_none_or(|o| at < o) {
1847 oldest = Some(at);
1848 }
1849 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1850 if seen.insert(ed.inner_id) {
1851 editions.push(ed);
1852 }
1853 }
1854 }
1855 a = fold_authority(community, &editions, &floors);
1856 if !a.gapped || fresh == 0 {
1857 break;
1858 }
1859 until = oldest;
1860 }
1861 AuthorityView {
1862 roles: a.roles,
1863 banned: a.banned,
1864 gapped: a.gapped,
1865 floored: floors.keys().cloned().collect(),
1866 head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
1867 banned_at: a.banned_at,
1868 }
1869}
1870
1871async fn fetch_guestbook_events<T: Transport + ?Sized>(
1883 transport: &T,
1884 community: &CommunityV2,
1885 since_secs: u64,
1886) -> Result<(Vec<guestbook::GuestbookEvent>, u64), String> {
1887 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1888 const GB_PAGE: usize = 500;
1889 const GB_MAX_PAGES: usize = 12;
1890 let mut events = Vec::new();
1891 let mut newest: u64 = since_secs;
1892 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1893 let mut until: Option<u64> = None;
1894 let mut oldest: Option<u64> = None;
1895 for _ in 0..GB_MAX_PAGES {
1896 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() };
1899 let wraps = transport.fetch(&query, &community.relays).await?;
1900 let mut fresh = 0usize;
1901 for wrap in &wraps {
1902 if !seen.insert(wrap.id) {
1903 continue;
1904 }
1905 fresh += 1;
1906 let at = wrap.created_at.as_secs();
1907 if oldest.is_none_or(|o| at < o) {
1908 oldest = Some(at);
1909 }
1910 if at > newest {
1911 newest = at;
1912 }
1913 if at < since_secs {
1915 continue;
1916 }
1917 if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
1918 if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
1919 events.push(ev);
1920 }
1921 }
1922 }
1923 if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
1924 break;
1925 }
1926 match oldest {
1927 Some(o) if o > 0 => until = Some(o),
1928 _ => break,
1929 }
1930 }
1931 Ok((events, newest))
1932}
1933
1934fn fold_members(
1939 community: &CommunityV2,
1940 events: &[guestbook::GuestbookEvent],
1941 mut observed: std::collections::BTreeMap<PublicKey, u64>,
1942 roles: &crate::community::roles::CommunityRoles,
1943 banlist: &std::collections::BTreeSet<PublicKey>,
1944 banned_at: &std::collections::BTreeMap<PublicKey, u64>,
1945) -> Result<Vec<PublicKey>, String> {
1946 let owner = community.owner()?;
1947 let owner_hex = owner.to_hex();
1948 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1949
1950 for g in &roles.grants {
1957 if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
1958 observed.entry(pk).or_insert(0);
1959 }
1960 }
1961
1962 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1971 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
1975 let actor_hex = actor.to_hex();
1976 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
1977 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1978 };
1979 let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
1980 let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at);
1981 if !banlist.contains(&owner) {
1983 members.insert(owner);
1984 }
1985 Ok(members.into_iter().collect())
1986}
1987
1988pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
1998 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1999 let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
2000 return false;
2001 };
2002 let Ok(owner) = community.owner() else { return false };
2003 let owner_hex = owner.to_hex();
2004 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2005 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
2006 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2007 let actor_hex = actor.to_hex();
2008 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2009 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2010 };
2011 matches!(
2012 guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
2013 Some(st) if st.verdict == guestbook::Verdict::Kicked
2014 )
2015}
2016
2017pub async fn sync_guestbook<T: Transport + ?Sized>(
2022 transport: &T,
2023 community: &CommunityV2,
2024 session: &SessionGuard,
2025) -> Result<Vec<guestbook::GuestbookEvent>, String> {
2026 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2027 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2028 let since = cursor.saturating_sub(1);
2031 let (fresh, newest) = fetch_guestbook_events(transport, community, since).await?;
2032 if !session.is_valid() {
2033 return Err("account changed during guestbook sync".to_string());
2034 }
2035 let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
2036 let mut added = Vec::new();
2037 for ev in fresh {
2038 if !known.contains(&ev.rumor_id) {
2039 events.push(ev.clone());
2040 added.push(ev);
2041 }
2042 }
2043 if !added.is_empty() || newest > cursor {
2044 crate::db::community::set_guestbook(&cid_hex, &events, newest.max(cursor))?;
2045 }
2046 Ok(added)
2047}
2048
2049pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
2052 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2053 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2054 if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
2055 return Ok(false);
2056 }
2057 events.push(ev);
2058 crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
2059 Ok(true)
2060}
2061
2062pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2069 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2070 let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2071 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2072 for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
2073 if let Ok(pk) = PublicKey::parse(&npub) {
2074 observed.insert(pk, last_active_secs.saturating_mul(1000));
2075 }
2076 }
2077 let roles = crate::db::community::get_community_roles(&cid_hex)?;
2078 let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2079 .unwrap_or_default()
2080 .iter()
2081 .filter_map(|h| PublicKey::from_hex(h).ok())
2082 .collect();
2083 let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
2086 .unwrap_or_default()
2087 .into_iter()
2088 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2089 .collect();
2090 fold_members(community, &events, observed, &roles, &banlist, &banned_at)
2091}
2092
2093pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2099 let (events, _newest) = fetch_guestbook_events(transport, community, 0).await?;
2100 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2103 for ch in &community.channels {
2104 if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
2105 for f in &page {
2106 let e = observed.entry(f.event.opened().author).or_insert(0);
2107 *e = (*e).max(f.event.opened().at_ms);
2108 }
2109 }
2110 }
2111
2112 let authority = fetch_authority(transport, community).await;
2117 let banlist: std::collections::BTreeSet<PublicKey> =
2119 authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
2120 let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
2124 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
2125 )
2126 .unwrap_or_default()
2127 .into_iter()
2128 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2129 .collect();
2130 for (h, at) in &authority.banned_at {
2131 if let Ok(pk) = PublicKey::from_hex(h) {
2132 let slot = banned_at.entry(pk).or_insert(0);
2133 *slot = (*slot).max(*at);
2134 }
2135 }
2136 fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
2137}
2138
2139pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2147 let session = SessionGuard::capture();
2148 let signer = crate::signer::active_signer()?;
2149 let my_pk = me_pk()?;
2150 if community.owner()? != my_pk {
2151 return Err("only the owner can dissolve a community".to_string());
2152 }
2153 let at = now_ms() / 1000;
2154 let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
2155 let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
2156 if !session.is_valid() {
2157 return Err("account changed during dissolve".to_string());
2158 }
2159 transport.publish_durable(&wrap, &community.relays).await?;
2161 crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2162 Ok(())
2163}
2164
2165pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
2170 let group = super::derive::dissolved_group_key(community.id());
2171 let query = Query {
2172 kinds: vec![stream::KIND_WRAP],
2173 authors: vec![group.pk_hex()],
2174 limit: Some(20),
2175 ..Default::default()
2176 };
2177 let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
2178 return false;
2179 };
2180 wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
2181}
2182
2183pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
2196 let session = SessionGuard::capture();
2197 let cid = community.id();
2198 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2199 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2201 return Err("this community has been dissolved; it cannot be re-founded".to_string());
2202 }
2203 let signer = crate::signer::active_signer()?;
2204 let my_pk = me_pk()?;
2205 let lock = super::realtime::follow_lock(cid);
2209 let _guard = lock.lock().await;
2210 let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
2214 let community = &fresh;
2215 let owner = community.owner()?;
2216
2217 {
2224 let owner_hex = owner.to_hex();
2225 let me_hex = my_pk.to_hex();
2226 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2232 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2233 let authorized = my_pk == owner
2234 || (!banned.contains(&me_hex)
2235 && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
2236 && removed.iter().all(|t| {
2237 roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
2238 }));
2239 if !authorized {
2240 return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
2241 }
2242 }
2243
2244 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2248 .into_iter()
2249 .filter(|(_, f)| f.0 == community.root_epoch.0)
2250 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2251 .collect();
2252 let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2253 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2260 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2261 let mut oldest: Option<u64> = None;
2262 let mut until: Option<u64> = None;
2263 let mut truncated = false;
2267 for page in 0..COMPACT_MAX_PAGES {
2268 let query = Query {
2272 kinds: vec![stream::KIND_WRAP],
2273 authors: vec![current_control.pk_hex()],
2274 until,
2275 limit: Some(FOLLOW_PAGE),
2276 evidence: crate::community::transport::Evidence::Full,
2277 ..Default::default()
2278 };
2279 let wraps = transport.fetch(&query, &community.relays).await?;
2280 let mut fresh = 0usize;
2281 for w in &wraps {
2282 if !seen_wraps.insert(w.id) {
2283 continue;
2284 }
2285 fresh += 1;
2286 let at = w.created_at.as_secs();
2287 if oldest.is_none_or(|o| at < o) {
2288 oldest = Some(at);
2289 }
2290 if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
2291 opened.push(parsed);
2292 }
2293 }
2294 if fresh == 0 {
2295 truncated = wraps.len() >= FOLLOW_PAGE;
2299 break;
2300 }
2301 until = oldest;
2302 if page + 1 == COMPACT_MAX_PAGES {
2303 truncated = true;
2304 }
2305 }
2306 if truncated {
2307 return Err(
2308 "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(),
2309 );
2310 }
2311
2312 let prev_epoch = community.root_epoch;
2313 let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
2314 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2315 if !session.is_valid() {
2321 return Err("account changed during re-founding compaction".to_string());
2322 }
2323 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2324 let new_control = control_group_key(&new_root, cid, new_epoch);
2325 let at = now_ms();
2326 let at_secs = at / 1000;
2327
2328 use std::collections::BTreeMap;
2334 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2335 for (i, (e, _)) in opened.iter().enumerate() {
2336 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2337 }
2338 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2339 for (floor_key, floor) in &floors {
2340 let head_idx = by_eid
2348 .get(floor_key)
2349 .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2350 let Some(head_idx) = head_idx else {
2351 return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
2352 };
2353 let (head_ed, head_os) = &opened[head_idx];
2354 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2355 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2356 carried.push((h, rewrapped));
2357 }
2358 if !session.is_valid() {
2359 return Err("account changed during re-founding acquire".to_string());
2360 }
2361
2362 let members = memberlist(transport, community).await?;
2364 let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
2365 let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
2366 if !recipients.iter().any(|p| *p == my_pk) {
2367 recipients.push(my_pk);
2368 }
2369
2370 let mut base_blobs = Vec::new();
2372 for r in &recipients {
2373 base_blobs.push(
2374 super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2375 .await
2376 .map_err(|e| e.to_string())?,
2377 );
2378 }
2379 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2380 let base_chunks =
2381 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())
2382 .await
2383 .map_err(|e| e.to_string())?;
2384
2385 let mut roster_for_channels = fetch_authority(transport, community).await.roles;
2397 {
2398 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2399 for r in cached.roles {
2400 if !roster_for_channels.roles.iter().any(|x| x.role_id == r.role_id) {
2401 roster_for_channels.roles.push(r);
2402 }
2403 }
2404 for g in cached.grants {
2405 if !roster_for_channels.grants.iter().any(|x| x.member == g.member) {
2406 roster_for_channels.grants.push(g);
2407 }
2408 }
2409 }
2410 if !session.is_valid() {
2411 return Err("account changed during re-founding entitlement fetch".to_string());
2412 }
2413 let owner_hex_for_channels = community.owner().ok().map(|o| o.to_hex());
2414 let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
2415 let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
2416 for ch in &community.channels {
2417 let (Some(old_key), true) = (ch.key, ch.private) else { continue };
2418 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
2419 let entitled: Vec<PublicKey> = recipients
2420 .iter()
2421 .copied()
2422 .filter(|r| {
2423 *r == my_pk
2424 || roster_for_channels.is_entitled(owner_hex_for_channels.as_deref(), &r.to_hex(), &ch_hex, &[], &[])
2425 })
2426 .collect();
2427 let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2428 if !session.is_valid() {
2432 return Err("account changed during re-founding channel prepare".to_string());
2433 }
2434 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)?;
2435 let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
2436 let mut ch_blobs = Vec::new();
2437 for r in &entitled {
2438 ch_blobs.push(
2439 super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
2440 .await
2441 .map_err(|e| e.to_string())?,
2442 );
2443 }
2444 let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
2445 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())
2446 .await
2447 .map_err(|e| e.to_string())?;
2448 channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
2449 channel_chunk_sets.push(ch_chunks);
2450 }
2451 if !session.is_valid() {
2452 return Err("account changed during re-founding prepare".to_string());
2453 }
2454
2455 for c in &base_chunks {
2458 transport.publish_durable(c, &community.relays).await?;
2459 }
2460 for set in &channel_chunk_sets {
2461 for c in set {
2462 transport.publish_durable(c, &community.relays).await?;
2463 }
2464 }
2465 for (_, wrap) in &carried {
2466 transport.publish_durable(wrap, &community.relays).await?;
2467 }
2468 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2471 let snap_id = crate::community::random_32();
2472 for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
2473 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
2474 let _ = transport.publish(&wrap, &community.relays).await;
2475 }
2476 }
2477
2478 if !session.is_valid() {
2480 return Err("account changed during re-founding commit".to_string());
2481 }
2482 if crate::db::community::community_protocol(cid)?.is_none() {
2483 return Ok(community.clone()); }
2485 let mut updated = community.clone();
2489 updated.community_root = new_root;
2490 updated.root_epoch = new_epoch;
2491 for (id, key, ep) in &channel_updates {
2492 if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
2493 c.key = Some(*key);
2494 c.epoch = *ep;
2495 }
2496 }
2497 crate::db::community::save_community_v2(&updated)?;
2498 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2503 for (h, _) in &carried {
2504 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2505 }
2506 if let Some(client) = crate::state::nostr_client() {
2512 super::realtime::refresh_subscription(&client).await;
2513 }
2514 for attempt in 0..3u8 {
2520 match refresh_public_links(transport, &updated).await {
2521 Ok(()) => break,
2522 Err(_) if !session.is_valid() => break, Err(e) if attempt == 2 => {
2524 crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
2525 }
2526 Err(_) => continue,
2527 }
2528 }
2529 Ok(updated)
2530}
2531
2532pub async fn refound_at_birth<T: Transport + ?Sized>(
2548 transport: &T,
2549 community: &CommunityV2,
2550 snapshot_members: &[PublicKey],
2551) -> Result<CommunityV2, String> {
2552 let session = SessionGuard::capture();
2553 let cid = community.id();
2554 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2555 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2558 return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
2559 }
2560 let signer = crate::signer::active_signer()?;
2561 let my_pk = me_pk()?;
2562 if my_pk != community.owner()? {
2563 return Err("only the owner can birth-refound the migration twin".to_string());
2564 }
2565 let lock = super::realtime::follow_lock(cid);
2566 let _guard = lock.lock().await;
2567 let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
2568 if community.root_epoch.0 == 1 {
2574 return Ok(community);
2575 }
2576 if community.root_epoch.0 != 0 {
2577 return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
2578 }
2579 let community = &community;
2580
2581 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2585 .into_iter()
2586 .filter(|(_, f)| f.0 == community.root_epoch.0)
2587 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2588 .collect();
2589 let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2590 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2591 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2592 let mut oldest: Option<u64> = None;
2593 let mut until: Option<u64> = None;
2594 let mut truncated = false;
2596 for page in 0..COMPACT_MAX_PAGES {
2597 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() };
2598 let wraps = transport.fetch(&query, &community.relays).await?;
2599 let mut fresh = 0usize;
2600 for w in &wraps {
2601 if !seen_wraps.insert(w.id) { continue; }
2602 fresh += 1;
2603 let at = w.created_at.as_secs();
2604 if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
2605 if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
2606 opened.push(parsed);
2607 }
2608 }
2609 if fresh == 0 {
2610 truncated = wraps.len() >= FOLLOW_PAGE;
2611 break;
2612 }
2613 until = oldest;
2614 if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
2615 }
2616 if truncated {
2617 return Err(
2618 "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(),
2619 );
2620 }
2621
2622 let prev_epoch = community.root_epoch; let new_epoch = Epoch(1);
2624 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2625 if !session.is_valid() {
2626 return Err("account changed during birth-refound compaction".to_string());
2627 }
2628 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2629 let new_control = control_group_key(&new_root, cid, new_epoch);
2630 let at = now_ms();
2631 let at_secs = at / 1000;
2632
2633 use std::collections::BTreeMap;
2634 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2635 for (i, (e, _)) in opened.iter().enumerate() {
2636 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2637 }
2638 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2639 for (floor_key, floor) in &floors {
2640 let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2641 let Some(head_idx) = head_idx else {
2642 return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
2643 };
2644 let (head_ed, head_os) = &opened[head_idx];
2645 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2646 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2647 carried.push((h, rewrapped));
2648 }
2649 if !session.is_valid() {
2650 return Err("account changed during birth-refound acquire".to_string());
2651 }
2652
2653 let base_blobs = vec![
2655 super::rekey::build_blob(&signer, &my_pk.to_bytes(), &my_pk, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2656 .await
2657 .map_err(|e| e.to_string())?,
2658 ];
2659 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2660 let base_chunks =
2661 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())
2662 .await
2663 .map_err(|e| e.to_string())?;
2664 if !session.is_valid() {
2665 return Err("account changed during birth-refound prepare".to_string());
2666 }
2667
2668 for c in &base_chunks {
2670 transport.publish_durable(c, &community.relays).await?;
2671 }
2672 for (_, wrap) in &carried {
2673 transport.publish_durable(wrap, &community.relays).await?;
2674 }
2675 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2683 let snap_id = crate::community::random_32();
2684 let snapshot_wraps: Vec<Event> = {
2685 let mut out = Vec::new();
2686 for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
2687 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
2688 .await
2689 .map_err(|e| format!("seal birth snapshot: {e}"))?;
2690 out.push(wrap);
2691 }
2692 out
2693 };
2694 for wrap in &snapshot_wraps {
2695 transport.publish_durable(wrap, &community.relays).await?;
2696 }
2697 let verify_view = {
2706 let mut v = community.clone();
2707 v.community_root = new_root;
2708 v.root_epoch = new_epoch;
2709 v
2710 };
2711 let expected: Vec<PublicKey> = {
2712 let banlist = fetch_authority(transport, &verify_view).await.banned;
2713 snapshot_members.iter().copied()
2714 .filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
2715 .collect()
2716 };
2717 if !expected.is_empty() {
2718 let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
2719 let missing = expected.iter().filter(|m| !folded.contains(m)).count();
2720 if missing > 0 {
2721 return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
2722 }
2723 }
2724
2725 if !session.is_valid() {
2727 return Err("account changed during birth-refound commit".to_string());
2728 }
2729 if crate::db::community::community_protocol(cid)?.is_none() {
2730 return Ok(community.clone());
2731 }
2732 let mut updated = community.clone();
2733 updated.community_root = new_root;
2734 updated.root_epoch = new_epoch;
2735 crate::db::community::save_community_v2(&updated)?;
2736 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2737 for (h, _) in &carried {
2738 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2739 }
2740 Ok(updated)
2741}
2742
2743fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
2750 if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
2751 return Ok(existing);
2752 }
2753 let fresh = crate::community::random_32();
2754 crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
2755 Ok(fresh)
2756}
2757
2758fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
2764 let hex = crate::simd::hex::bytes_to_hex_32;
2765 let channels = community
2766 .channels
2767 .iter()
2768 .filter(|c| c.private)
2769 .filter_map(|c| {
2773 c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: Some(hex(&k)), epoch: c.epoch.0, name: c.name.clone() })
2774 })
2775 .collect();
2776 super::list::JoinMaterial {
2777 community_id: hex(&community.identity.community_id.0),
2778 owner: hex(&community.identity.owner_xonly),
2779 owner_salt: hex(&community.identity.owner_salt),
2780 community_root: hex(&community.community_root),
2781 root_epoch: community.root_epoch.0,
2782 channels,
2783 relays: community.relays.clone(),
2784 name: community.name.clone(),
2785 extra: Default::default(),
2786 }
2787}
2788
2789fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
2793 let channels = jm
2796 .channels
2797 .iter()
2798 .filter_map(|c| {
2799 c.key.as_ref().map(|k| invite::ChannelGrant { id: c.id.clone(), key: k.clone(), epoch: c.epoch, name: c.name.clone() })
2800 })
2801 .collect();
2802 CommunityInvite {
2803 community_id: jm.community_id.clone(),
2804 owner: jm.owner.clone(),
2805 owner_salt: jm.owner_salt.clone(),
2806 community_root: jm.community_root.clone(),
2807 root_epoch: jm.root_epoch,
2808 channels,
2809 relays: jm.relays.clone(),
2810 name: jm.name.clone(),
2811 icon: None,
2812 expires_at: None,
2813 creator_npub: None,
2814 label: None,
2815 extra: Default::default(),
2816 }
2817}
2818
2819fn held_v2_relays() -> Vec<String> {
2822 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2823 if let Ok(ids) = crate::db::community::list_community_ids() {
2824 for id in ids {
2825 if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2826 if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
2827 set.extend(c.relays);
2828 }
2829 }
2830 }
2831 }
2832 set.into_iter().collect()
2833}
2834
2835async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
2841 let signer = crate::signer::active_signer()?;
2842 let my_pk = me_pk()?;
2843 let query = Query {
2844 kinds: vec![super::kind::COMMUNITY_LIST],
2845 authors: vec![my_pk.to_hex()],
2846 limit: Some(4),
2847 ..Default::default()
2848 };
2849 let events = transport.fetch(&query, relays).await?;
2850 let seen = events.len();
2851 let mut undecryptable = 0usize;
2855 let mut unreadable: Option<(u64, String, usize, String)> = None;
2856 let mut best: Option<(u64, String, super::list::CommunityList)> = None;
2857 for e in events {
2858 let at = e.created_at.as_secs();
2859 let id_hex = e.id.to_hex();
2860 let content_len = e.content.len();
2861 match super::list::parse_list_event_signed(&signer, my_pk, &e).await {
2862 Ok(l) => {
2863 if best.as_ref().map(|(b, _, _)| at > *b).unwrap_or(true) {
2864 best = Some((at, id_hex, l));
2865 }
2866 }
2867 Err(err) => {
2868 undecryptable += 1;
2869 if unreadable.as_ref().map(|(a, _, _, _)| at > *a).unwrap_or(true) {
2870 unreadable = Some((at, id_hex, content_len, err.to_string()));
2871 }
2872 }
2873 }
2874 }
2875 if let Some((at, id, len, err)) = &unreadable {
2879 if best.as_ref().map(|(b, _, _)| at > b).unwrap_or(true) {
2880 crate::log_net_fail!(
2881 "[CommunityList] IGNORED a newer copy {} created_at={at} ({len} content bytes) — falling back to stale membership: {err}",
2882 &id[..8]
2883 );
2884 }
2885 }
2886 if let Some((at, id, l)) = &best {
2887 let bytes = serde_json::to_string(l).map(|s| s.len()).unwrap_or(0);
2888 crate::log_debug!(
2889 "[CommunityList] using {} created_at={at} ({bytes}/{} bytes) of {seen} copies, {undecryptable} unreadable",
2890 &id[..8],
2891 super::stream::NIP44_MAX_PLAINTEXT
2892 );
2893 }
2894 Ok(best.map(|(_, _, l)| l))
2895}
2896
2897pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
2909 let session = SessionGuard::capture();
2910 let signer = crate::signer::active_signer()?;
2911 let my_pk = me_pk()?;
2912 let relays = held_v2_relays();
2913 if relays.is_empty() {
2914 return Ok(false); }
2916 let remote = match fetch_community_list(transport, &relays).await {
2920 Ok(r) => r.unwrap_or_default(),
2921 Err(e) => {
2922 crate::log_warn!(
2928 "[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
2929 e,
2930 just_joined
2931 .map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
2932 .unwrap_or_default()
2933 );
2934 return Ok(false);
2935 }
2936 };
2937 let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
2938 let now = now_ms();
2939 let mut local = super::list::CommunityList::default();
2940 for id in crate::db::community::list_community_ids()? {
2941 if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2942 continue;
2943 }
2944 let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
2945 let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
2946 let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
2947 let tombstoned_at = remote
2959 .tombstones
2960 .iter()
2961 .find(|t| t.community_id == cid_hex)
2962 .map(|t| t.removed_at)
2963 .unwrap_or(0);
2964 let held_since = c.created_at_ms;
2965 if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
2966 crate::log_warn!(
2967 "[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
2968 &cid_hex[..8], tombstoned_at, held_since
2969 );
2970 continue;
2971 }
2972 let added_at = if remote.is_live(&cid_hex) && !is_join {
2977 remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
2978 } else if !is_join && tombstoned_at > 0 {
2979 held_since
2980 } else {
2981 now
2982 };
2983 let jm = join_material(&c);
2984 local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
2985 }
2986 let merged = remote.merge(&local);
2987 merged.assert_fits().map_err(|e| e.to_string())?;
2988 let event = super::list::build_list_event_signed(&signer, my_pk, &merged).await.map_err(|e| e.to_string())?;
2989 if !session.is_valid() {
2990 return Err("account changed during community-list publish".to_string());
2991 }
2992 if let Err(e) = transport.publish(&event, &relays).await {
2993 crate::log_warn!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
2994 return Err(e);
2995 }
2996 Ok(true)
2997}
2998
2999const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
3004
3005pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
3018 if crate::state::nostr_client().is_none() {
3019 return;
3020 }
3021 let session = SessionGuard::capture();
3022 tokio::spawn(async move {
3023 for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
3024 if !session.is_valid() {
3025 return;
3026 }
3027 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3028 match republish_community_list(&transport, just_joined.as_ref()).await {
3029 Ok(true) => {
3030 if attempt > 0 {
3031 crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
3032 }
3033 return;
3034 }
3035 Ok(false) => {} Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
3037 }
3038 tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
3039 }
3040 crate::log_warn!(
3041 "[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
3042 LIST_REPUBLISH_BACKOFF_SECS.len()
3043 );
3044 });
3045}
3046
3047async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
3050 let signer = crate::signer::active_signer()?;
3051 let my_pk = me_pk()?;
3052 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3053 let mut doc = match fetch_community_list(transport, relays).await {
3057 Ok(d) => d.unwrap_or_default(),
3058 Err(e) => return Err(e),
3059 };
3060 let now = now_ms();
3061 doc.tombstones.retain(|t| t.community_id != cid_hex);
3062 doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
3063 doc.assert_fits().map_err(|e| e.to_string())?;
3064 let event = super::list::build_list_event_signed(&signer, my_pk, &doc).await.map_err(|e| e.to_string())?;
3065 transport.publish(&event, relays).await
3066}
3067
3068pub struct ListSyncOutcome {
3075 pub joined: Vec<CommunityV2>,
3077 pub removed: Vec<(String, Vec<String>)>,
3084}
3085
3086pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
3087 let session = SessionGuard::capture();
3088 let mut relays = held_v2_relays();
3089 relays.extend(bootstrap_relays.iter().cloned());
3090 relays.sort();
3091 relays.dedup();
3092 if relays.is_empty() {
3093 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3094 }
3095 let list = match fetch_community_list(transport, &relays).await {
3099 Ok(Some(l)) => {
3100 crate::log_debug!(
3101 "[CommunityList] fetched: {} entries, {} tombstones, across {} relays",
3102 l.entries.len(),
3103 l.tombstones.len(),
3104 relays.len()
3105 );
3106 l
3107 }
3108 Ok(None) => {
3109 crate::log_debug!("[CommunityList] no kind-13302 across {} relays", relays.len());
3113 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3114 }
3115 Err(e) => {
3116 crate::log_net_fail!("[CommunityList] fetch failed across {} relays: {e}", relays.len());
3117 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3118 }
3119 };
3120 let mut removed: Vec<(String, Vec<String>)> = Vec::new();
3126 for t in &list.tombstones {
3127 if list.is_live(&t.community_id) {
3128 continue;
3129 }
3130 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
3131 let id = crate::community::CommunityId(cid);
3132 let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
3133 continue; };
3135 if held.created_at_ms > t.removed_at {
3141 crate::log_warn!(
3142 "[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
3143 &t.community_id[..8], t.removed_at, held.created_at_ms
3144 );
3145 continue;
3146 }
3147 if !session.is_valid() {
3148 return Err("account changed during community-list sync".to_string());
3149 }
3150 let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
3151 let _ = crate::db::community::delete_community(&t.community_id);
3152 removed.push((t.community_id.clone(), channel_ids));
3153 }
3154 let mut joined = Vec::new();
3155 for entry in list.live_entries() {
3156 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
3157 if crate::db::community::load_community_v2(&crate::community::CommunityId(cid)).ok().flatten().is_some() {
3158 continue; }
3160 if !session.is_valid() {
3161 return Err("account changed during community-list sync".to_string());
3162 }
3163 let bundle = material_to_invite(&entry.current);
3168 match accept_bundle(transport, &session, &bundle, None, false).await {
3169 Ok(community) => joined.push(community),
3170 Err(e) => crate::log_net_fail!(
3174 "[CommunityList] {} is listed but adoption failed: {e}",
3175 &entry.community_id[..entry.community_id.len().min(8)]
3176 ),
3177 }
3178 }
3179 Ok(ListSyncOutcome { joined, removed })
3180}
3181
3182pub(super) fn citation_is_synced(
3212 cid_hex: &str,
3213 owner_hex: &str,
3214 actor_hex: &str,
3215 citation: Option<&crate::community::edition::AuthorityCitation>,
3216) -> bool {
3217 if owner_hex == actor_hex {
3218 return true;
3219 }
3220 if citation.is_none() {
3221 return false;
3222 }
3223 let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
3224 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
3225 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
3226 &crate::community::CommunityId(cid_bytes),
3227 &actor_bytes,
3228 ));
3229 let head: Vec<crate::community::roster::EntityHead> =
3230 crate::db::community::get_edition_head(cid_hex, &grant_hex)
3231 .ok()
3232 .flatten()
3233 .map(|(version, self_hash)| crate::community::roster::EntityHead {
3234 entity_hex: grant_hex.clone(),
3235 version,
3236 self_hash,
3237 inner_id: [0u8; 32],
3238 citation: None,
3239 })
3240 .into_iter()
3241 .collect();
3242 crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
3243}
3244
3245fn required_authority_citation(
3253 community: &CommunityV2,
3254 actor: &PublicKey,
3255) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
3256 if community.owner().ok().as_ref() == Some(actor) {
3257 return Ok(None); }
3259 my_authority_citation(community, actor).map(Some).ok_or_else(|| {
3260 "your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
3261 })
3262}
3263
3264fn my_authority_citation(
3265 community: &CommunityV2,
3266 actor: &PublicKey,
3267) -> Option<crate::community::edition::AuthorityCitation> {
3268 if community.owner().ok().as_ref() == Some(actor) {
3269 return None;
3270 }
3271 let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
3272 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3273 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
3274 crate::db::community::get_edition_head(&cid_hex, &entity_hex)
3275 .ok()
3276 .flatten()
3277 .map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
3278}
3279
3280fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
3288 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3289 match crate::db::community::get_server_root_epoch(&cid_hex)? {
3290 Some(held) if held != community.root_epoch.0 => Err(format!(
3291 "the community re-founded mid-action (epoch {} -> {held}); retry",
3292 community.root_epoch.0
3293 )),
3294 _ => Ok(()), }
3296}
3297
3298async fn publish_control_edition<T: Transport + ?Sized>(
3299 transport: &T,
3300 community: &CommunityV2,
3301 session: &SessionGuard,
3302 vsk: &str,
3303 entity_id: &[u8; 32],
3304 content: &str,
3305) -> Result<(), String> {
3306 assert_current_root(community)?;
3307 let signer = crate::signer::active_signer()?;
3308 let my_pk = me_pk()?;
3309 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
3310 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3311 let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
3312 let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
3313 Some((v, h)) => (v + 1, Some(h)),
3314 None => (1, None),
3315 };
3316 let citation = required_authority_citation(community, &my_pk)?;
3322 let at = now_ms() / 1000;
3323 let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
3324 let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
3325 if !session.is_valid() {
3326 return Err("account changed before control publish".to_string());
3327 }
3328 transport.publish(&wrap, &community.relays).await?;
3329 if !session.is_valid() {
3335 return Ok(());
3336 }
3337 if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
3338 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
3339 }
3340 Ok(())
3341}
3342
3343fn merge_local_roster(cid_hex: &str, role: Option<&crate::community::roles::Role>, grant: Option<&crate::community::roles::MemberGrant>) {
3352 let mut roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
3353 if let Some(r) = role {
3354 match roster.roles.iter_mut().find(|x| x.role_id == r.role_id) {
3355 Some(slot) => *slot = r.clone(),
3356 None => roster.roles.push(r.clone()),
3357 }
3358 }
3359 if let Some(g) = grant {
3360 match roster.grants.iter_mut().find(|x| x.member == g.member) {
3361 Some(slot) => *slot = g.clone(),
3362 None => roster.grants.push(g.clone()),
3363 }
3364 }
3365 let at = crate::db::community::get_community_roles_at(cid_hex).unwrap_or(0);
3366 if let Err(e) = crate::db::community::set_community_roles(cid_hex, &roster, at) {
3367 crate::log_warn!("v2: local roster merge failed (heals on the next control fold): {e}");
3368 }
3369}
3370
3371pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
3375 let session = SessionGuard::capture();
3376 super::roles::validate_role(role)?;
3377 let content = super::roles::role_content_json(role)?;
3378 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
3379 publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content).await
3380}
3381
3382pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
3386 let session = SessionGuard::capture();
3387 let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
3388 let content = super::roles::grant_content_json(&grant)?;
3389 let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
3390 publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content).await
3391}
3392
3393pub async fn ensure_admin_role<T: Transport + ?Sized>(
3398 transport: &T,
3399 community: &CommunityV2,
3400 view: &AuthorityView,
3401 create_if_missing: bool,
3402) -> Result<Option<String>, String> {
3403 use crate::community::roles::{Permissions, Role, RoleScope};
3404 if let Some(r) = view
3405 .roles
3406 .roles
3407 .iter()
3408 .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_ALL))
3409 {
3410 return Ok(Some(r.role_id.clone()));
3411 }
3412 if !create_if_missing {
3413 return Ok(None);
3414 }
3415 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3416 let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
3417 set_role(transport, community, &Role::admin(role_id.clone())).await?;
3418 Ok(Some(role_id))
3419}
3420
3421pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3428 let session = SessionGuard::capture();
3431 let my_pk = me_pk()?;
3432 if my_pk != community.owner()? {
3433 return Err("only the community owner can grant @admin".to_string());
3434 }
3435 let view = fetch_authority(transport, community).await;
3436 if !session.is_valid() {
3437 return Err("account changed during grant".to_string());
3438 }
3439 let member_hex = member.to_hex();
3440 require_grant_head(community, &view, &member_hex)?;
3441 let role_id = ensure_admin_role(transport, community, &view, true)
3442 .await?
3443 .expect("create_if_missing yields an id");
3444 let mut role_ids = view
3445 .roles
3446 .grants
3447 .iter()
3448 .find(|g| g.member == member_hex)
3449 .map(|g| g.role_ids.clone())
3450 .unwrap_or_default();
3451 if role_ids.contains(&role_id) {
3452 return Ok(()); }
3454 role_ids.push(role_id);
3455 grant_roles(transport, community, member, role_ids).await
3456}
3457
3458pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3461 let session = SessionGuard::capture();
3462 let my_pk = me_pk()?;
3463 if my_pk != community.owner()? {
3464 return Err("only the community owner can revoke @admin".to_string());
3465 }
3466 let view = fetch_authority(transport, community).await;
3467 if !session.is_valid() {
3468 return Err("account changed during revoke".to_string());
3469 }
3470 let member_hex = member.to_hex();
3471 require_grant_head(community, &view, &member_hex)?;
3472 let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
3473 return Ok(()); };
3475 let mut role_ids = view
3476 .roles
3477 .grants
3478 .iter()
3479 .find(|g| g.member == member_hex)
3480 .map(|g| g.role_ids.clone())
3481 .unwrap_or_default();
3482 let before = role_ids.len();
3483 role_ids.retain(|r| r != &role_id);
3484 if role_ids.len() == before {
3485 return Ok(());
3486 }
3487 grant_roles(transport, community, member, role_ids).await
3488}
3489
3490fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
3494 let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
3495 return Err("malformed member key".to_string());
3496 };
3497 let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
3498 if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
3499 return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
3500 }
3501 Ok(())
3502}
3503
3504pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
3507 let session = SessionGuard::capture();
3508 super::roles::validate_banlist(banned)?;
3509 let content = super::roles::banlist_content_json(banned)?;
3510 let eid = super::derive::banlist_locator(community.id());
3511 publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content).await
3512}
3513
3514pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
3517 let session = SessionGuard::capture();
3518 control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
3519 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3520 publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
3521}
3522
3523pub async fn persist_community_image(
3528 id: &crate::community::CommunityId,
3529 img: control::ImageRef,
3530 is_banner: bool,
3531 session: &SessionGuard,
3532) -> Option<CommunityV2> {
3533 let lock = super::realtime::follow_lock(id);
3534 let _guard = lock.lock().await;
3535 if !session.is_valid() {
3536 return None;
3537 }
3538 let mut fresh = crate::db::community::load_community_v2(id).ok()??;
3539 if is_banner {
3540 fresh.banner = Some(img);
3541 } else {
3542 fresh.icon = Some(img);
3543 }
3544 crate::db::community::save_community_v2(&fresh).ok()?;
3545 Some(fresh)
3546}
3547
3548pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
3551 let session = SessionGuard::capture();
3552 let my_pk = me_pk()?;
3553 ensure_channel_manager(community, &my_pk)?;
3554 let old_name = community.channel(channel_id).map(|c| c.name.clone());
3555 if meta.private {
3560 if let Some(held) = community.channel(channel_id) {
3561 if !held.private {
3562 return Err("converting a public channel to private is not supported yet".to_string());
3563 }
3564 }
3565 }
3566 control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
3567 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3568 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3569 if !session.is_valid() {
3573 return Ok(());
3574 }
3575 if let Ok(Some(mut held)) = crate::db::community::load_community_v2(community.id()) {
3576 if let Some(ch) = held.channels.iter_mut().find(|c| c.id.0 == channel_id.0) {
3577 ch.name = meta.name.clone();
3578 ch.private = meta.private;
3579 ch.voice = meta.voice;
3580 ch.meta_custom = meta.custom.clone();
3581 ch.meta_extra = meta.extra.clone();
3582 crate::db::community::save_community_v2(&held)?;
3583 }
3584 }
3585 if meta.private {
3587 if let Some(old) = old_name.filter(|o| *o != meta.name) {
3588 rename_channel_access_role(transport, community, channel_id, &old, &meta.name, &session).await;
3589 }
3590 }
3591 Ok(())
3592}
3593
3594async fn rename_channel_access_role<T: Transport + ?Sized>(
3601 transport: &T,
3602 community: &CommunityV2,
3603 channel_id: &ChannelId,
3604 old_name: &str,
3605 new_name: &str,
3606 session: &SessionGuard,
3607) {
3608 let (Ok(my_pk), Ok(owner)) = (me_pk(), community.owner()) else {
3609 return;
3610 };
3611 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3612 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3613 let mut roster = fetch_authority(transport, community).await.roles;
3616 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3617 for r in cached.roles {
3618 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
3619 roster.roles.push(r);
3620 }
3621 }
3622 if !session.is_valid() {
3623 return;
3624 }
3625 let (me_hex, owner_hex) = (my_pk.to_hex(), owner.to_hex());
3629 if !roster.is_authorized_in(&me_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
3630 return;
3631 }
3632 let Some(mut role) = roster
3635 .channel_roles(&chan_hex)
3636 .into_iter()
3637 .find(|r| r.permissions == crate::community::roles::Permissions::empty() && r.name == old_name)
3638 .cloned()
3639 else {
3640 return;
3641 };
3642 if !roster.can_act_on_position(&me_hex, Some(&owner_hex), role.position, crate::community::roles::Permissions::MANAGE_ROLES) {
3643 return;
3644 }
3645 role.name = new_name.to_string();
3646 if let Err(e) = set_role(transport, community, &role).await {
3647 crate::log_warn!("v2: channel renamed but its access role did not follow: {e}");
3648 return;
3649 }
3650 if session.is_valid() {
3651 merge_local_roster(&cid_hex, Some(&role), None);
3652 }
3653}
3654
3655fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
3661 let owner = community.owner()?;
3662 if *me == owner {
3663 return Ok(());
3664 }
3665 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3666 let me_hex = me.to_hex();
3667 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
3668 return Err("you are banned from this community".to_string());
3669 }
3670 let roster = crate::db::community::get_community_roles(&cid_hex)?;
3671 if roster.is_authorized(&me_hex, Some(&owner.to_hex()), crate::community::roles::Permissions::MANAGE_CHANNELS) {
3672 Ok(())
3673 } else {
3674 Err("managing channels here needs the MANAGE_CHANNELS permission".to_string())
3675 }
3676}
3677
3678pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3684 let channel_id = ChannelId(super::super::random_32());
3685 create_public_channel_with_id(transport, community, name, channel_id).await?;
3686 Ok(channel_id)
3687}
3688
3689pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3693 let session = SessionGuard::capture();
3694 let lock = super::realtime::follow_lock(community.id());
3698 let _guard = lock.lock().await;
3699 let my_pk = me_pk()?;
3700 ensure_channel_manager(community, &my_pk)?;
3701 assert_channel_id_free(&channel_id, community.id())?;
3702 let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
3703 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3704 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3705 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3706 if !session.is_valid() {
3707 return Err("account changed during channel create".to_string());
3708 }
3709 let mut updated = community.clone();
3711 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() });
3712 crate::db::community::save_community_v2(&updated)?;
3713 Ok(())
3714}
3715
3716fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
3721 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3722 if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
3723 let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3724 let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
3725 if existing != mine
3726 && matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
3727 {
3728 return Err("channel id is already live in another v2 community".to_string());
3729 }
3730 }
3731 Ok(())
3732}
3733
3734pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3746 let channel_id = ChannelId(super::super::random_32());
3747 create_private_channel_with_id(transport, community, name, channel_id).await?;
3748 Ok(channel_id)
3749}
3750
3751pub fn channel_access_role(channel_id: &ChannelId, name: &str) -> crate::community::roles::Role {
3756 use crate::community::roles::{Permissions, Role, RoleScope};
3757 Role {
3758 role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
3759 name: name.to_string(),
3760 position: u32::MAX - 1,
3761 permissions: Permissions::empty(),
3762 scope: RoleScope::Channel(crate::simd::hex::bytes_to_hex_32(&channel_id.0)),
3763 color: 0,
3764 }
3765}
3766
3767pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3770 let session = SessionGuard::capture();
3771 let lock = super::realtime::follow_lock(community.id());
3775 let _guard = lock.lock().await;
3776 let signer = crate::signer::active_signer()?;
3777 let my_pk = me_pk()?;
3778 ensure_channel_manager(community, &my_pk)?;
3779 assert_channel_id_free(&channel_id, community.id())?;
3780 let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
3781 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3782 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3783
3784 let channel_key = super::super::random_32();
3785 let epoch = Epoch(1);
3786
3787 let access_role = channel_access_role(&channel_id, name);
3790 let access_role_ids = vec![access_role.role_id.clone()];
3791
3792 let owner = community.owner()?;
3796 let mut recipients = vec![my_pk];
3797 if owner != my_pk {
3798 recipients.push(owner);
3799 }
3800 let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
3801 let mut blobs = Vec::with_capacity(recipients.len());
3802 for r in &recipients {
3803 blobs.push(
3804 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
3805 .await
3806 .map_err(|e| e.to_string())?,
3807 );
3808 }
3809 let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
3810 let at_secs = now_ms() / 1000;
3811 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())
3812 .await
3813 .map_err(|e| e.to_string())?;
3814 if !session.is_valid() {
3815 return Err("account changed during channel create".to_string());
3816 }
3817 for c in &chunks {
3818 transport.publish_durable(c, &community.relays).await?;
3819 }
3820 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3821 if !session.is_valid() {
3822 return Err("account changed during channel create".to_string());
3823 }
3824 set_role(transport, community, &access_role).await?;
3829 grant_roles(transport, community, &my_pk, access_role_ids.clone()).await?;
3830 if !session.is_valid() {
3831 return Err("account changed during channel create".to_string());
3832 }
3833 merge_local_roster(
3836 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
3837 Some(&access_role),
3838 Some(&crate::community::roles::MemberGrant { member: my_pk.to_hex(), role_ids: access_role_ids }),
3839 );
3840 if crate::db::community::community_protocol(community.id())?.is_none() {
3842 return Err("community removed during channel create".to_string());
3843 }
3844 let mut updated = community.clone();
3845 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() });
3846 crate::db::community::save_community_v2(&updated)?;
3847 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3850 crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
3851 let _ = refresh_public_links(transport, &updated).await;
3854 Ok(())
3855}
3856
3857#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3862pub enum VendVerdict {
3863 Accept,
3865 Park(&'static str),
3869 Refuse(&'static str),
3871}
3872
3873pub fn judge_channel_key_vend(
3885 community: &CommunityV2,
3886 roster: &crate::community::roles::CommunityRoles,
3887 channel_id: &ChannelId,
3888 epoch: Epoch,
3889 sender_hex: &str,
3890) -> VendVerdict {
3891 let me = match me_pk() {
3892 Ok(pk) => pk.to_hex(),
3893 Err(_) => return VendVerdict::Park("no active identity"),
3894 };
3895 let owner_hex = match community.owner() {
3896 Ok(o) => o.to_hex(),
3897 Err(_) => return VendVerdict::Refuse("community has no resolvable owner"),
3898 };
3899 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3900
3901 let Some(ch) = community.channel(channel_id) else {
3904 return VendVerdict::Park("channel not in our fold yet");
3905 };
3906 if !ch.private {
3907 return VendVerdict::Refuse("vend names a channel our fold says is public");
3910 }
3911
3912 if ch.key.is_some() && epoch.0 <= ch.epoch.0 {
3920 return VendVerdict::Refuse("superseded: we already hold this epoch or newer");
3921 }
3922 if epoch.0 > ch.epoch.0.saturating_add(MAX_VEND_EPOCH_LEAD) {
3923 return VendVerdict::Refuse("vend epoch is implausibly far ahead of the channel head");
3924 }
3925
3926 if !roster.is_entitled(Some(&owner_hex), &me, &chan_hex, &[], &[]) {
3930 return VendVerdict::Park("our grant for this channel has not folded yet");
3931 }
3932
3933 if sender_hex != owner_hex && !roster.is_entitled(Some(&owner_hex), sender_hex, &chan_hex, &[], &[]) {
3936 return VendVerdict::Park("vendor's entitlement has not folded yet");
3937 }
3938
3939 VendVerdict::Accept
3940}
3941
3942const PARKED_VEND_TTL_SECS: u64 = 30 * 24 * 3600;
3945
3946const MAX_VEND_EPOCH_LEAD: u64 = 1024;
3950
3951pub fn absorb_parked_channel_keys(community: &CommunityV2, session: &SessionGuard) -> Vec<ChannelId> {
3957 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3958 let parked = match crate::db::community::get_pending_channel_keys(&cid_hex) {
3959 Ok(p) if !p.is_empty() => p,
3960 _ => return Vec::new(),
3961 };
3962 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3963 let mut adopted = Vec::new();
3964 let now = now_ms() / 1000;
3965 for p in parked {
3966 if adopted.iter().any(|c: &ChannelId| crate::simd::hex::bytes_to_hex_32(&c.0) == p.channel_id) {
3970 let _ = crate::db::community::drop_pending_channel_key(p.id);
3971 continue;
3972 }
3973 if now.saturating_sub(p.received_at.max(0) as u64) > PARKED_VEND_TTL_SECS {
3979 let _ = crate::db::community::drop_pending_channel_key(p.id);
3980 continue;
3981 }
3982 let Some(id_bytes) = crate::simd::hex::hex_to_bytes_32_checked(&p.channel_id) else {
3983 let _ = crate::db::community::drop_pending_channel_key(p.id);
3984 continue;
3985 };
3986 let channel_id = ChannelId(id_bytes);
3987 match judge_channel_key_vend(community, &roster, &channel_id, Epoch(p.epoch), &p.sender) {
3988 VendVerdict::Accept => {
3989 if !session.is_valid() {
3990 return adopted;
3991 }
3992 let keyless = community.channel(&channel_id).is_some_and(|c| c.key.is_none());
3997 let seated = if keyless {
3998 crate::db::community::seat_channel_key(&cid_hex, &p.channel_id, p.epoch, &p.key)
3999 } else {
4000 crate::db::community::advance_channel_epoch(&cid_hex, &p.channel_id, p.epoch, &p.key).map(|_| ())
4001 };
4002 if let Err(e) = seated {
4003 crate::log_warn!("v2: adopting a vended channel key failed: {e}");
4004 continue;
4005 }
4006 let _ = crate::db::community::drop_pending_channel_keys_for(&cid_hex, &p.channel_id);
4008 adopted.push(channel_id);
4009 }
4010 VendVerdict::Refuse(why) => {
4011 crate::log_warn!("v2: refused a vended channel key for {}: {why}", p.channel_id);
4012 let _ = crate::db::community::drop_pending_channel_key(p.id);
4014 }
4015 VendVerdict::Park(_) => {}
4017 }
4018 }
4019 adopted
4020}
4021
4022pub async fn grant_channel_access<T: Transport + ?Sized>(
4032 transport: &T,
4033 community: &CommunityV2,
4034 channel_id: &ChannelId,
4035 member: &PublicKey,
4036) -> Result<(), String> {
4037 let session = SessionGuard::capture();
4038 let my_pk = me_pk()?;
4039 let ch = community.channel(channel_id).ok_or("unknown channel")?;
4040 if !ch.private {
4041 return Err("channel is public — every member already reads it".to_string());
4042 }
4043 if ch.key.is_none() {
4044 return Err("we hold no key for this channel, so we cannot vend it".to_string());
4045 }
4046 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4047 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4048 let owner_hex = community.owner()?.to_hex();
4049 let mut roster = fetch_authority(transport, community).await.roles;
4055 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4056 for r in cached.roles {
4057 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4058 roster.roles.push(r);
4059 }
4060 }
4061 for g in cached.grants {
4062 if !roster.grants.iter().any(|x| x.member == g.member) {
4063 roster.grants.push(g);
4064 }
4065 }
4066 if !session.is_valid() {
4067 return Err("account changed during grant".to_string());
4068 }
4069 if !roster.is_authorized_in(&my_pk.to_hex(), Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
4072 return Err("not authorized to manage this channel's access".to_string());
4073 }
4074 let role_id = roster
4079 .channel_roles(&chan_hex)
4080 .into_iter()
4081 .find(|r| r.permissions == crate::community::roles::Permissions::empty())
4082 .map(|r| r.role_id.clone())
4083 .ok_or("channel has no permission-less access role to grant")?;
4084
4085 let mut role_ids: Vec<String> = roster.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect();
4086 if !role_ids.contains(&role_id) {
4087 role_ids.push(role_id.clone());
4088 }
4089 grant_roles(transport, community, member, role_ids.clone()).await?;
4090 if !session.is_valid() {
4091 return Err("account changed during grant".to_string());
4092 }
4093 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids }));
4094 let bundle = bundle_of_with_overlay(
4096 community,
4097 BundleAudience::Member(*member),
4098 Some(my_pk),
4099 None,
4100 None,
4101 std::slice::from_ref(&role_id),
4102 &[],
4103 );
4104 let signer = crate::signer::active_signer()?;
4105 let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
4106 if !session.is_valid() {
4107 return Err("account changed before vending the key".to_string());
4108 }
4109 transport.publish(&wrap, &community.relays).await?;
4110 Ok(())
4111}
4112
4113pub async fn revoke_channel_access<T: Transport + ?Sized>(
4119 transport: &T,
4120 community: &CommunityV2,
4121 channel_id: &ChannelId,
4122 member: &PublicKey,
4123) -> Result<(), String> {
4124 let session = SessionGuard::capture();
4125 let my_pk = me_pk()?;
4126 let ch = community.channel(channel_id).ok_or("unknown channel")?;
4127 if !ch.private {
4128 return Err("channel is public — there is no access to revoke".to_string());
4129 }
4130 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4131 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4132 let owner_hex = community.owner()?.to_hex();
4133 let mut roster = fetch_authority(transport, community).await.roles;
4136 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4137 for r in cached.roles {
4138 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4139 roster.roles.push(r);
4140 }
4141 }
4142 for g in cached.grants {
4143 if !roster.grants.iter().any(|x| x.member == g.member) {
4144 roster.grants.push(g);
4145 }
4146 }
4147 if !session.is_valid() {
4148 return Err("account changed during revoke".to_string());
4149 }
4150 if !roster.is_authorized_in(&my_pk.to_hex(), Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
4151 return Err("not authorized to manage this channel's access".to_string());
4152 }
4153 if *member == community.owner()? {
4154 return Err("the owner is supreme and cannot be removed".to_string());
4155 }
4156 let access_ids = roster.channel_role_ids(&chan_hex);
4157 if access_ids.is_empty() {
4161 return Err("this channel's access role has not folded yet — retry once the control plane serves it".to_string());
4162 }
4163 let remaining: Vec<String> = roster
4164 .roles_of(&member.to_hex())
4165 .map(|r| r.role_id.clone())
4166 .filter(|id| !access_ids.contains(id))
4167 .collect();
4168 grant_roles(transport, community, member, remaining.clone()).await?;
4169 if !session.is_valid() {
4170 return Err("account changed during revoke".to_string());
4171 }
4172 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids: remaining }));
4173 rekey_channel_excluding(transport, community, channel_id, &roster, &access_ids, member).await
4177}
4178
4179async fn rekey_channel_excluding<T: Transport + ?Sized>(
4189 transport: &T,
4190 community: &CommunityV2,
4191 channel_id: &ChannelId,
4192 roster: &crate::community::roles::CommunityRoles,
4193 access_ids: &[String],
4194 removed: &PublicKey,
4195) -> Result<(), String> {
4196 let session = SessionGuard::capture();
4197 let lock = super::realtime::follow_lock(community.id());
4199 let _guard = lock.lock().await;
4200 let signer = crate::signer::active_signer()?;
4201 let my_pk = me_pk()?;
4202 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4203 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4204 let ch = community.channel(channel_id).ok_or("unknown channel")?.clone();
4205 let old_key = ch.key.ok_or("we hold no key for this channel, so we cannot rotate it")?;
4206 let new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
4207 let owner = community.owner()?;
4208 let owner_hex = owner.to_hex();
4209
4210 let removed_hex = removed.to_hex();
4214 let mut recipients: Vec<PublicKey> = vec![my_pk];
4215 if owner != my_pk {
4216 recipients.push(owner);
4217 }
4218 for g in &roster.grants {
4219 if g.member == removed_hex || g.member == owner_hex {
4220 continue;
4221 }
4222 if !g.role_ids.iter().any(|id| access_ids.contains(id)) {
4223 continue;
4224 }
4225 if let Ok(pk) = PublicKey::parse(&g.member) {
4226 if !recipients.contains(&pk) {
4227 recipients.push(pk);
4228 }
4229 }
4230 }
4231 let new_key = mint_or_reuse_rotation_key(&cid_hex, &chan_hex, new_epoch.0)?;
4234 let prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
4235 let mut blobs = Vec::with_capacity(recipients.len());
4236 for r in &recipients {
4237 blobs.push(
4238 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(*channel_id), new_epoch, &new_key)
4239 .await
4240 .map_err(|e| e.to_string())?,
4241 );
4242 }
4243 let group = channel_rekey_group_key(&community.community_root, channel_id, new_epoch);
4244 let at_secs = now_ms() / 1000;
4245 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())
4246 .await
4247 .map_err(|e| e.to_string())?;
4248 if !session.is_valid() {
4249 return Err("account changed during channel rekey".to_string());
4250 }
4251 for c in &chunks {
4252 transport.publish_durable(c, &community.relays).await?;
4253 }
4254 if !session.is_valid() {
4255 return Err("account changed during channel rekey".to_string());
4256 }
4257 if crate::db::community::community_protocol(community.id())?.is_none() {
4258 return Err("community removed during channel rekey".to_string());
4259 }
4260 crate::db::community::advance_channel_epoch(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
4262 crate::db::community::store_epoch_key(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
4263 Ok(())
4264}
4265
4266pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
4269 let session = SessionGuard::capture();
4270 let lock = super::realtime::follow_lock(community.id());
4272 let _guard = lock.lock().await;
4273 let my_pk = me_pk()?;
4274 ensure_channel_manager(community, &my_pk)?;
4275 let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
4279 name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
4280 });
4281 meta.deleted = Some(true);
4282 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
4283 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
4284 if !session.is_valid() {
4285 return Err("account changed during channel delete".to_string());
4286 }
4287 let mut updated = community.clone();
4288 updated.channels.retain(|c| c.id.0 != channel_id.0);
4289 crate::db::community::save_community_v2(&updated)?;
4290 Ok(())
4291}
4292
4293pub async fn follow_control<T: Transport + ?Sized>(
4317 transport: &T,
4318 community: &CommunityV2,
4319 session: &SessionGuard,
4320) -> Result<Option<CommunityV2>, String> {
4321 community.owner()?; let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
4323 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4324
4325 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
4331 .into_iter()
4332 .filter(|(_, f)| f.0 == community.root_epoch.0)
4333 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
4334 .collect();
4335
4336 let mut editions: Vec<ParsedEdition> = Vec::new();
4340 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
4341 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
4342 let mut oldest: Option<u64> = None;
4343 let mut until: Option<u64> = None;
4344 let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false };
4345 let mut authority = AuthoritySet::owner_only();
4346 let mut truncated = true;
4352 for _ in 0..FOLLOW_MAX_PAGES {
4353 let query = Query {
4357 kinds: vec![stream::KIND_WRAP],
4358 authors: vec![control.pk_hex()],
4359 until,
4360 limit: Some(FOLLOW_PAGE),
4361 evidence: crate::community::transport::Evidence::Quorum,
4362 ..Default::default()
4363 };
4364 let wraps = transport.fetch(&query, &community.relays).await?;
4365 let mut fresh = 0usize;
4369 for w in &wraps {
4370 if !seen_wraps.insert(w.id) {
4371 continue;
4372 }
4373 fresh += 1;
4374 let at = w.created_at.as_secs();
4375 if oldest.is_none_or(|o| at < o) {
4376 oldest = Some(at);
4377 }
4378 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
4381 if seen.insert(ed.inner_id) {
4382 editions.push(ed);
4383 }
4384 }
4385 }
4386 authority = fold_authority(community, &editions, &floors);
4389 fold = apply_control_fold(community, &editions, &floors, &authority);
4390 if !(fold.gapped || authority.gapped) {
4391 truncated = false; break;
4393 }
4394 if fresh == 0 {
4395 truncated = wraps.len() >= FOLLOW_PAGE;
4400 break;
4401 }
4402 until = oldest;
4403 }
4404
4405 if !session.is_valid() {
4408 return Err("account changed during control follow".to_string());
4409 }
4410 if crate::db::community::community_protocol(community.id())?.is_none() {
4413 return Ok(None);
4414 }
4415 for h in fold.heads.iter().chain(authority.heads.iter()) {
4421 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)?;
4422 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)?;
4423 }
4424 let mut authority_changed = false;
4427 let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
4431 if let Some((banned, version)) = &authority.banlist_persist {
4432 let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4433 crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
4434 let mut after = banned.clone();
4435 before.sort();
4436 after.sort();
4437 authority_changed |= before != after;
4438 }
4439 let newest_roster_at: i64 = editions
4446 .iter()
4447 .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
4448 .map(|e| e.created_at as i64)
4449 .max()
4450 .unwrap_or(0);
4451 let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4457 let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
4458 let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
4459 && stored.grants.iter().all(|g| {
4460 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
4461 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
4462 !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
4463 })
4464 });
4465 if !truncated && !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
4470 authority_changed |= stored != authority.roles;
4471 crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
4472 }
4473 if !truncated && !authority.gapped && !fold.gapped {
4479 if let Ok(owner) = community.owner() {
4480 let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
4481 let live = flatten_link_sets(&sets);
4482 let mut before = crate::db::community::get_community_invite_registry(&cid_hex).unwrap_or_default();
4483 before.sort();
4484 if before != live {
4485 crate::db::community::set_community_invite_registry(&cid_hex, &live)?;
4486 authority_changed = true;
4487 }
4488 crate::db::community::replace_invite_link_sets(&cid_hex, &sets)?;
4491 }
4492 }
4493 if authority_changed {
4498 crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
4499 }
4500 match fold.updated {
4501 Some(u) => {
4502 crate::db::community::save_community_v2(&u)?;
4503 Ok(Some(u))
4504 }
4505 None => Ok(None),
4506 }
4507}
4508
4509const FOLLOW_MAX_PAGES: usize = 32;
4518const FOLLOW_PAGE: usize = 500;
4519const COMPACT_MAX_PAGES: usize = 512;
4524
4525#[derive(Clone)]
4527struct FoldedHead {
4528 entity_hex: String,
4529 version: u64,
4530 self_hash: [u8; 32],
4531 inner_id: [u8; 32],
4532}
4533
4534struct ControlFold {
4540 updated: Option<CommunityV2>,
4541 heads: Vec<FoldedHead>,
4542 gapped: bool,
4543}
4544
4545type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
4547
4548fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
4569 use crate::community::roles::Permissions;
4570 use std::collections::BTreeMap;
4571
4572 let owner_hex = community.owner().ok().map(|o| o.to_hex());
4573
4574 let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
4575 for e in editions {
4576 groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
4577 }
4578
4579 let mut out = community.clone();
4580 let mut changed = false;
4581 let mut heads = Vec::new();
4582 let mut gapped = false;
4583 for ((vsk_code, eid), group) in &groups {
4584 let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
4589 let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
4590 if !is_meta && !is_channel {
4591 continue;
4592 }
4593 let required = if is_meta { Permissions::MANAGE_METADATA } else { Permissions::MANAGE_CHANNELS };
4598 let authed: Vec<&ParsedEdition> = group
4599 .iter()
4600 .copied()
4601 .filter(|e| {
4602 let author = e.author.to_hex();
4603 !authority.banned.contains(&author)
4606 && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
4607 && citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
4611 })
4612 .collect();
4613 if authed.is_empty() {
4614 continue;
4615 }
4616 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
4617 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
4618 let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
4619 gapped |= entity_gapped;
4620 let Some(hi) = hi else { continue };
4621
4622 let head = authed[hi];
4623 heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
4624 if is_meta {
4625 if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
4626 changed |= apply_community_metadata(&mut out, meta);
4627 }
4628 } else if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
4629 changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
4633 }
4634 }
4635 ControlFold { updated: changed.then_some(out), heads, gapped }
4636}
4637
4638fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
4645 let floor_v = floor.map(|f| f.0).unwrap_or(0);
4646 if floor_v == 0 {
4647 return (version::bootstrap_head(fold_eds, 0), false);
4648 }
4649 let floor_hash = floor.map(|f| &f.1);
4650 let held_inner = floor.and_then(|f| f.2);
4651 let result = version::fold(fold_eds, floor_v, floor_hash);
4652 if result.anchored {
4653 return (result.head, result.gap); }
4655 if result.head.is_none() && !result.gap {
4656 return (None, false); }
4658 let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
4662 let win_hash = match fork {
4663 Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
4664 _ => return (None, true), };
4666 let re = version::fold(fold_eds, floor_v, Some(&win_hash));
4667 if !re.anchored {
4668 return (None, true);
4669 }
4670 (re.head, re.gap)
4671}
4672
4673struct AuthoritySet {
4678 roles: crate::community::roles::CommunityRoles,
4679 banned: std::collections::BTreeSet<String>,
4680 heads: Vec<FoldedHead>,
4681 gapped: bool,
4682 banlist_persist: Option<(Vec<String>, u64)>,
4686 banned_at: std::collections::BTreeMap<String, u64>,
4690}
4691
4692impl AuthoritySet {
4693 fn owner_only() -> Self {
4696 AuthoritySet {
4697 roles: Default::default(),
4698 banned: Default::default(),
4699 heads: vec![],
4700 gapped: false,
4701 banlist_persist: None,
4702 banned_at: Default::default(),
4703 }
4704 }
4705}
4706
4707fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
4714 use crate::community::roles::Permissions;
4715 use std::collections::BTreeMap;
4716
4717 let cid = community.id();
4718 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
4719 let owner = community.owner().ok();
4720 let owner_hex = owner.map(|o| o.to_hex());
4721 let banlist_eid = super::derive::banlist_locator(cid);
4722 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
4723
4724 let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
4725 for e in editions {
4726 if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
4727 groups.entry(e.entity_id).or_default().push(e);
4728 }
4729 }
4730
4731 let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
4739 let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
4740 let mut gapped = false;
4741
4742 for (eid, group) in &groups {
4743 if *eid == banlist_eid {
4745 continue;
4746 }
4747 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
4748 let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
4749 let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
4750 gapped |= entity_gapped;
4751 let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
4752
4753 for p in group {
4754 if p.version < floor_v {
4756 continue;
4757 }
4758 let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
4759 match p.vsk.as_str() {
4760 vsk::ROLE => {
4761 if let Some(role) = super::roles::parse_role_content(&p.content) {
4763 if role.role_id == entity_hex && role.position != 0 {
4764 role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
4765 }
4766 }
4767 }
4768 vsk::GRANT => {
4769 if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
4770 if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
4771 if super::derive::grant_locator(cid, &member) == *eid {
4772 grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
4773 grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
4774 }
4775 }
4776 }
4777 }
4778 _ => {}
4779 }
4780 }
4781 }
4782 for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
4783 cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
4784 }
4785
4786 let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4787 let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty);
4790
4791 let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4802 let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
4807 let banlist_authored: Vec<&ParsedEdition> = groups
4808 .get(&banlist_eid)
4809 .map(|g| {
4810 g.iter()
4811 .copied()
4812 .filter(|e| {
4813 let ah = e.author.to_hex();
4814 !banned_authors.contains(ah.as_str())
4815 && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
4816 && citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
4817 })
4818 .collect()
4819 })
4820 .unwrap_or_default();
4821 let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
4827 for p in &banlist_authored {
4828 for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
4829 if owner_hex.as_deref() == Some(t.as_str()) {
4830 continue;
4831 }
4832 let slot = banned_at.entry(t).or_insert(0);
4833 *slot = (*slot).max(p.created_at);
4834 }
4835 }
4836 let mut banlist_persist: Option<(Vec<String>, u64)> = None;
4837 let mut banlist_head: Option<FoldedHead> = None;
4838 let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
4839 persisted_banned.into_iter().collect()
4840 } else {
4841 let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
4842 let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
4843 gapped |= g;
4844 match hi {
4845 Some(hi) => {
4846 let head = banlist_authored[hi];
4847 let ah = head.author.to_hex();
4848 let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
4849 .unwrap_or_default()
4850 .into_iter()
4851 .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
4852 .take(super::roles::MAX_BANLIST)
4853 .collect();
4854 banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
4855 banlist_persist = Some((list.clone(), head.version));
4856 list.into_iter().collect()
4857 }
4858 None => persisted_banned.into_iter().collect(),
4859 }
4860 };
4861
4862 let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned);
4866 if let Some(bh) = banlist_head {
4867 heads.push(bh);
4868 }
4869
4870 if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
4875 authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
4876 authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
4877 let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
4878 authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
4879 authorized.grants.retain(|g| !g.role_ids.is_empty());
4880 }
4881
4882 AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
4883}
4884
4885struct AuthorityCand {
4888 role: Option<crate::community::roles::Role>,
4889 grant: Option<crate::community::roles::MemberGrant>,
4890 author: PublicKey,
4891 head: FoldedHead,
4892 citation: Option<crate::community::edition::AuthorityCitation>,
4895}
4896
4897fn citation_ok_in_fold(
4906 cid: &crate::community::CommunityId,
4907 heads: &[FoldedHead],
4908 owner_hex: Option<&str>,
4909 author: &PublicKey,
4910 citation: Option<&crate::community::edition::AuthorityCitation>,
4911) -> bool {
4912 let actor_hex = author.to_hex();
4913 if owner_hex == Some(actor_hex.as_str()) {
4914 return true;
4915 }
4916 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
4917 let as_entity: Vec<crate::community::roster::EntityHead> = heads
4918 .iter()
4919 .map(|h| crate::community::roster::EntityHead {
4920 entity_hex: h.entity_hex.clone(),
4921 version: h.version,
4922 self_hash: h.self_hash,
4923 inner_id: h.inner_id,
4924 citation: None,
4925 })
4926 .collect();
4927 crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
4928}
4929
4930fn select_authorized(
4939 cid: &crate::community::CommunityId,
4940 role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
4941 grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
4942 owner_hex: Option<&str>,
4943 excluded: &std::collections::BTreeSet<String>,
4944) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
4945 use crate::community::roles::{CommunityRoles, Permissions};
4946 let mut accepted = CommunityRoles::default();
4947 let mut heads: Vec<FoldedHead> = Vec::new();
4948 let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
4953 for _ in 0..bound {
4954 let mut next = CommunityRoles::default();
4955 let mut next_heads: Vec<FoldedHead> = Vec::new();
4956
4957 for cands in role_cands.values() {
4958 let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
4971 let mut standing: Option<u32> = None;
4972 let mut i = cands.len();
4973 while i > 0 {
4974 let hi = i;
4975 let ver = cands[i - 1].head.version;
4976 while i > 0 && cands[i - 1].head.version == ver {
4977 i -= 1;
4978 }
4979 for c in cands[i..hi].iter().rev() {
4981 let Some(role) = &c.role else { continue };
4982 let ah = c.author.to_hex();
4983 if excluded.contains(&ah) || role.position == 0 {
4984 continue;
4985 }
4986 if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
4987 continue;
4988 }
4989 if let Some(prev) = standing {
4990 if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
4991 continue;
4992 }
4993 }
4994 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
4995 continue;
4996 }
4997 admissible.insert(c.head.self_hash);
4998 standing = Some(role.position);
4999 break;
5000 }
5001 }
5002 for c in cands {
5003 let Some(role) = &c.role else { continue };
5004 if !admissible.contains(&c.head.self_hash) {
5005 continue;
5006 }
5007 next.roles.push(role.clone());
5008 next_heads.push(c.head.clone());
5009 break; }
5011 }
5012 for cands in grant_cands.values() {
5013 for c in cands {
5014 let Some(grant) = &c.grant else { continue };
5015 let ah = c.author.to_hex();
5016 if excluded.contains(&ah) || excluded.contains(&grant.member) {
5017 continue;
5018 }
5019 let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
5022 let Some(positions) = positions else { continue };
5023 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
5024 continue;
5025 }
5026 if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
5027 && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
5028 {
5029 next_heads.push(c.head.clone());
5033 if !grant.role_ids.is_empty() {
5034 next.grants.push(grant.clone());
5035 }
5036 break;
5037 }
5038 }
5039 }
5040
5041 let converged = next.roles == accepted.roles && next.grants == accepted.grants;
5042 accepted = next;
5043 heads = next_heads;
5044 if converged {
5045 break;
5046 }
5047 }
5048 (accepted, heads)
5049}
5050
5051fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
5055 let mut changed = false;
5056 if out.name != meta.name {
5057 out.name = meta.name;
5058 changed = true;
5059 }
5060 if out.description != meta.description {
5061 out.description = meta.description;
5062 changed = true;
5063 }
5064 if out.icon != meta.icon {
5068 out.icon = meta.icon;
5069 changed = true;
5070 }
5071 if out.banner != meta.banner {
5072 out.banner = meta.banner;
5073 changed = true;
5074 }
5075 if out.meta_custom != meta.custom {
5078 out.meta_custom = meta.custom;
5079 changed = true;
5080 }
5081 if out.meta_extra != meta.extra {
5082 out.meta_extra = meta.extra;
5083 changed = true;
5084 }
5085 let relays = crate::community::cap_relays(meta.relays);
5093 if !relays.is_empty() && out.relays != relays {
5094 out.relays = relays;
5095 changed = true;
5096 }
5097 changed
5098}
5099
5100fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
5105 let deleted = meta.deleted.unwrap_or(false);
5106 if deleted {
5107 let before = out.channels.len();
5108 out.channels.retain(|c| c.id.0 != id.0);
5109 return out.channels.len() != before;
5110 }
5111 match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
5112 Some(existing) => {
5113 let mut changed = false;
5114 if existing.name != meta.name {
5115 existing.name = meta.name;
5116 changed = true;
5117 }
5118 if existing.voice != meta.voice {
5121 existing.voice = meta.voice;
5122 changed = true;
5123 }
5124 if existing.meta_custom != meta.custom {
5125 existing.meta_custom = meta.custom;
5126 changed = true;
5127 }
5128 if existing.meta_extra != meta.extra {
5129 existing.meta_extra = meta.extra;
5130 changed = true;
5131 }
5132 if !meta.private && (existing.private || existing.key.is_some()) {
5141 existing.private = false;
5142 existing.key = None;
5143 changed = true;
5144 }
5145 changed
5146 }
5147 None if !meta.private => {
5148 out.channels.push(ChannelV2 {
5151 id,
5152 name: meta.name,
5153 private: false,
5154 key: None,
5155 epoch: out.root_epoch,
5156 voice: meta.voice,
5157 meta_custom: meta.custom,
5158 meta_extra: meta.extra,
5159 });
5160 true
5161 }
5162 None => {
5163 out.channels.push(ChannelV2 {
5169 id,
5170 name: meta.name,
5171 private: true,
5172 key: None,
5173 epoch: Epoch(0),
5174 voice: meta.voice,
5175 meta_custom: meta.custom,
5176 meta_extra: meta.extra,
5177 });
5178 true
5179 }
5180 }
5181}
5182
5183pub struct RekeyFollow {
5187 pub updated: Option<CommunityV2>,
5190 pub self_removed: bool,
5193 pub dissolved: bool,
5196}
5197
5198const MAX_ADDRESSING_ROOTS: usize = 8;
5205
5206pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
5215 let mut roots: Vec<[u8; 32]> = vec![cur_root];
5216 let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
5217 .unwrap_or_default();
5218 archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
5219 for (_, r) in archived {
5220 if !roots.contains(&r) {
5221 roots.push(r);
5222 }
5223 }
5224 roots.truncate(MAX_ADDRESSING_ROOTS);
5225 roots
5226}
5227
5228#[cfg(debug_assertions)]
5263pub async fn debug_explain_base_rekey<T: Transport + ?Sized>(
5264 transport: &T,
5265 community: &CommunityV2,
5266) -> Result<serde_json::Value, String> {
5267 let my_xonly = me_pk()?.to_bytes();
5268 let owner = community.owner()?;
5269 let owner_hex = owner.to_hex();
5270 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5271 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5272 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5273 let held_epoch = community.root_epoch;
5274 let held_key = community.community_root;
5275 let next = Epoch(held_epoch.0.saturating_add(1));
5276 let group = base_rekey_group_key(&held_key, community.id(), next);
5277 let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
5278 let rotations = rekey::collect_rotations(&chunks);
5279
5280 let reports: Vec<serde_json::Value> = rotations
5281 .iter()
5282 .map(|r| {
5283 let rotator_is_owner = r.rotator == owner;
5284 let rotator_authorized = rotator_is_owner
5287 || (!banned.contains(&r.rotator.to_hex())
5288 && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
5289 let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
5290 let epoch_ok = r.new_epoch.0 == next.0;
5291 let complete = r.is_complete();
5292 let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
5293 let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
5294 let owner_kept = r.rotator == owner
5299 || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
5300 let verdict = if !rotator_authorized {
5302 "REJECTED: rotator holds no BAN authority in the folded roster"
5303 } else if !scope_ok {
5304 "REJECTED: scope is not Root"
5305 } else if !epoch_ok {
5306 "REJECTED: new_epoch != held+1"
5307 } else if !complete {
5308 "WAIT: rotation incomplete (missing chunk) — never concludes removal"
5309 } else if continuity != "Extends" {
5310 "REJECTED: continuity does not extend my held root (FORK/GAP)"
5311 } else if has_my_blob {
5312 "ADOPT: authorized + complete + continuous + my blob present"
5313 } else {
5314 "REMOVED: complete authorized rotation with no blob for me"
5315 };
5316 serde_json::json!({
5317 "rotator": r.rotator.to_hex(),
5318 "rotator_is_recorded_owner": rotator_is_owner,
5319 "rotator_authorized_ban": rotator_authorized,
5320 "scope_is_root": scope_ok,
5321 "new_epoch": r.new_epoch.0,
5322 "prev_epoch": r.prev_epoch.0,
5323 "declared_chunks": r.declared_chunks,
5324 "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
5325 "is_complete": complete,
5326 "continuity_vs_held_root": continuity,
5327 "my_blob_present": has_my_blob,
5328 "owner_kept": owner_kept,
5329 "blob_count": r.blobs.len(),
5330 "verdict": verdict,
5331 })
5332 })
5333 .collect();
5334
5335 Ok(serde_json::json!({
5336 "recorded_owner": owner.to_hex(),
5337 "held_root_epoch": held_epoch.0,
5338 "probing_next_epoch": next.0,
5339 "base_plane_pk": group.pk_hex(),
5340 "raw_chunks_parsed": chunks.len(),
5341 "rotations_found": rotations.len(),
5342 "rotations": reports,
5343 }))
5344}
5345
5346pub async fn follow_rekeys<T: Transport + ?Sized>(
5347 transport: &T,
5348 community: &CommunityV2,
5349 session: &SessionGuard,
5350) -> Result<RekeyFollow, String> {
5351 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5354 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
5355 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
5356 }
5357 if is_dissolved(transport, community).await {
5363 if session.is_valid() {
5364 let _ = crate::db::community::set_community_dissolved(&cid_hex);
5365 }
5366 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
5367 }
5368 let signer = crate::signer::active_signer()?;
5369 let my_pk = me_pk()?;
5370 let my_xonly = my_pk.to_bytes();
5371 let owner = community.owner()?;
5372 let owner_hex = owner.to_hex();
5373 let mut cur = community.clone();
5374 let mut changed = false;
5375
5376 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5388 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5389 let me_hex = my_pk.to_hex();
5390 let cited_ok = |rot: &rekey::Rotation| -> bool {
5399 citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
5400 };
5401 let channel_rotator_ok = |rotator: &PublicKey| -> bool {
5402 if *rotator == owner {
5403 return true;
5404 }
5405 let rh = rotator.to_hex();
5406 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
5407 };
5408 let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
5413 if *rotator == owner {
5414 return true;
5415 }
5416 let rh = rotator.to_hex();
5417 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
5418 };
5419 let base_rotator_ok = |rotator: &PublicKey| -> bool {
5425 if *rotator == owner {
5426 return true;
5427 }
5428 let rh = rotator.to_hex();
5429 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
5430 };
5431 let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
5437 if *rotator == owner {
5438 return true;
5439 }
5440 let rh = rotator.to_hex();
5441 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
5442 };
5443
5444 const MAX_STEPS: usize = 128;
5448 for _ in 0..MAX_STEPS {
5449 let mut advanced = false;
5450
5451 let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
5456
5457 let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
5460 for cid in channel_ids {
5461 let (held_key, held_epoch) = match cur.channel(&cid) {
5462 Some(ch) => (ch.key, ch.epoch),
5463 None => continue,
5464 };
5465 let next = Epoch(held_epoch.0.saturating_add(1));
5466 let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
5467 let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
5468 for (ri, root) in addressing_roots.iter().enumerate() {
5471 let group = channel_rekey_group_key(root, &cid, next);
5472 let chunks = match fetch_rekey_chunks(transport, &cur.relays, &group).await {
5473 Ok(c) => c,
5474 Err(e) => {
5475 crate::log_warn!(
5476 "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
5477 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
5478 );
5479 return Err(e);
5480 }
5481 };
5482 if chunks.is_empty() {
5483 continue;
5484 }
5485 crate::log_debug!(
5486 "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
5487 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
5488 );
5489 batches.push((chunks, held_key.map(|k| (held_epoch, k))));
5490 }
5491 match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
5498 Advance::Adopt { new_key } => {
5499 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
5500 ch.key = Some(new_key);
5501 ch.epoch = next;
5502 }
5503 crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
5504 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) {
5509 crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
5510 }
5511 advanced = true;
5512 changed = true;
5513 }
5514 Advance::Removed => {
5515 match held_key {
5516 Some(_) => {
5518 cur.channels.retain(|c| c.id.0 != cid.0);
5519 }
5520 None => {
5525 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
5526 ch.epoch = next;
5527 }
5528 }
5529 }
5530 advanced = true;
5531 changed = true;
5532 }
5533 Advance::Stay => {}
5534 }
5535 }
5536
5537 {
5541 let held_epoch = cur.root_epoch;
5542 let held_key = cur.community_root;
5543 let next = Epoch(held_epoch.0.saturating_add(1));
5544 let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
5545 let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
5546 let batches = vec![(chunks, Some((held_epoch, held_key)))];
5547 let base_admissible = |r: &rekey::Rotation| -> bool {
5554 if r.rotator == owner {
5555 return true; }
5557 if !cited_ok(r) {
5561 return false;
5562 }
5563 let rotator_hex = r.rotator.to_hex();
5564 let has_blob = |xonly: &[u8; 32]| {
5565 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
5566 };
5567 if !has_blob(&owner.to_bytes()) {
5569 return false;
5570 }
5571 for g in &roster.grants {
5572 if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
5573 continue; }
5575 if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
5577 if let Ok(pk) = PublicKey::from_hex(&g.member) {
5578 if !has_blob(&pk.to_bytes()) {
5579 return false; }
5581 }
5582 }
5583 }
5584 true
5585 };
5586 match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
5587 Advance::Adopt { new_key } => {
5588 cur.community_root = new_key;
5589 cur.root_epoch = next;
5590 if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
5594 crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
5595 }
5596 advanced = true;
5597 changed = true;
5598 }
5599 Advance::Removed => {
5600 if !session.is_valid() {
5601 return Err("account changed during rekey follow".to_string());
5602 }
5603 return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
5604 }
5605 Advance::Stay => {}
5606 }
5607 }
5608
5609 if !advanced {
5610 break;
5611 }
5612 }
5613
5614 if !changed {
5615 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
5616 }
5617 if !session.is_valid() {
5618 return Err("account changed during rekey follow".to_string());
5619 }
5620 if crate::db::community::community_protocol(community.id())?.is_none() {
5623 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
5624 }
5625 crate::db::community::save_community_v2(&cur)?;
5626 let _ = refresh_public_links(transport, &cur).await;
5634 Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
5635}
5636
5637enum Advance {
5640 Adopt { new_key: [u8; 32] },
5642 Removed,
5644 Stay,
5646}
5647
5648async fn fetch_rekey_chunks<T: Transport + ?Sized>(
5650 transport: &T,
5651 relays: &[String],
5652 group: &GroupKey,
5653) -> Result<Vec<rekey::RekeyChunk>, String> {
5654 const REKEY_PAGE: usize = 200;
5662 const REKEY_MAX_PAGES: usize = 6;
5663 let mut out = Vec::new();
5664 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
5665 let mut until: Option<u64> = None;
5666 let mut oldest: Option<u64> = None;
5667 for _ in 0..REKEY_MAX_PAGES {
5668 let query = Query {
5669 kinds: vec![stream::KIND_WRAP],
5670 authors: vec![group.pk_hex()],
5671 until,
5672 limit: Some(REKEY_PAGE),
5673 ..Default::default()
5674 };
5675 let wraps = transport.fetch_plane(group.keys(), &query, relays).await?;
5680 let mut fresh = 0usize;
5681 for w in &wraps {
5682 if !seen.insert(w.id) {
5683 continue;
5684 }
5685 fresh += 1;
5686 let at = w.created_at.as_secs();
5687 if oldest.is_none_or(|o| at < o) {
5688 oldest = Some(at);
5689 }
5690 if let Ok(opened) = stream::open_wrap(w, group) {
5691 if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
5692 out.push(chunk);
5693 }
5694 }
5695 }
5696 if fresh == 0 || wraps.len() < REKEY_PAGE {
5699 break;
5700 }
5701 match oldest {
5702 Some(o) if o > 0 => until = Some(o),
5703 _ => break,
5704 }
5705 }
5706 Ok(out)
5707}
5708
5709async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
5723 batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
5724 scope: RekeyScope,
5725 rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
5726 rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
5727 admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
5728 signer: &S,
5729 my_xonly: &[u8; 32],
5730 next_epoch: Epoch,
5731) -> Advance {
5732 let mut winners: Vec<[u8; 32]> = Vec::new();
5733 let mut saw_complete_candidate = false;
5734 let mut saw_outranking_candidate = false;
5735 let keyed = batches.iter().any(|(_, held)| held.is_some());
5736 for (chunks, held) in batches {
5737 let rotations = rekey::collect_rotations(chunks);
5738 for r in &rotations {
5739 if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
5740 continue;
5741 }
5742 if let Some((held_epoch, held_key)) = held {
5743 if r.continuity(*held_epoch, held_key) != Continuity::Extends {
5744 continue;
5745 }
5746 }
5747 if !admissible(r) {
5753 continue;
5754 }
5755 saw_complete_candidate = true;
5756 saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
5757 if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
5758 if let Ok(k) = rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob).await {
5759 winners.push(k);
5760 }
5761 }
5762 }
5763 }
5764 if !winners.is_empty() {
5765 let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
5773 return Advance::Adopt { new_key: winners[idx] };
5774 }
5775 if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
5776 Advance::Removed
5777 } else {
5778 Advance::Stay
5779 }
5780}
5781
5782#[cfg(test)]
5783mod tests {
5784 use crate::ClientRelayExt;
5785 use nostr_sdk::prelude::FinalizeEvent;
5786 use super::super::super::transport::memory::MemoryRelay;
5787 use super::*;
5788 use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
5789
5790 fn account_name(n: u32) -> String {
5792 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
5793 let mut acct = String::from("npub1");
5794 let mut v = n as usize;
5795 for _ in 0..58 {
5796 acct.push(B[v % 32] as char);
5797 v = v / 32 + 7;
5798 }
5799 acct
5800 }
5801
5802 struct Actor {
5804 keys: Keys,
5805 account: String,
5806 }
5807
5808 struct TestBed {
5814 _tmp: tempfile::TempDir,
5815 _guard: std::sync::MutexGuard<'static, ()>,
5816 relay: MemoryRelay,
5817 relays: Vec<String>,
5818 }
5819
5820 impl TestBed {
5821 fn new() -> (TestBed, Actor, Actor) {
5822 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
5823 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5824 crate::db::close_database();
5825 crate::db::clear_id_caches();
5826 let tmp = tempfile::tempdir().unwrap();
5827 crate::db::set_app_data_dir(tmp.path().to_path_buf());
5828
5829 let mk = || {
5830 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5831 let account = account_name(n);
5832 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
5833 crate::db::set_current_account(account.clone()).unwrap();
5834 crate::db::init_database(&account).unwrap();
5835 Actor { keys: Keys::generate(), account }
5836 };
5837 let owner = mk();
5838 let member = mk();
5839 let _ = crate::state::take_nostr_client();
5840 let bed = TestBed {
5841 _tmp: tmp,
5842 _guard: guard,
5843 relay: MemoryRelay::new(),
5844 relays: vec!["wss://r".to_string()],
5845 };
5846 (bed, owner, member)
5847 }
5848
5849 fn swap_to(&self, actor: &Actor) {
5854 crate::state::bump_session_generation();
5855 crate::db::set_current_account(actor.account.clone()).unwrap();
5856 crate::db::init_database(&actor.account).unwrap();
5857 crate::db::clear_id_caches();
5858 crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
5859 crate::state::set_my_public_key(actor.keys.public_key());
5860 }
5861 }
5862
5863 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
5865 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5866 crate::db::close_database();
5867 crate::db::clear_id_caches();
5868 let tmp = tempfile::tempdir().unwrap();
5869 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
5870 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5871 let acct = account_name(n);
5872 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
5873 crate::db::set_app_data_dir(tmp.path().to_path_buf());
5874 crate::db::set_current_account(acct.clone()).unwrap();
5875 crate::db::init_database(&acct).unwrap();
5876 let _ = crate::state::take_nostr_client();
5877 let owner = Keys::generate();
5878 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
5879 crate::state::set_my_public_key(owner.public_key());
5880 (tmp, guard, owner)
5881 }
5882
5883 struct SwapMidFetch {
5886 inner: MemoryRelay,
5887 }
5888 #[async_trait::async_trait]
5889 impl Transport for SwapMidFetch {
5890 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5891 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
5892 self.inner.publish(e, r).await
5893 }
5894 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5895 self.inner.publish_durable(e, r).await
5896 }
5897 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
5898 let out = self.inner.fetch(q, r).await;
5899 crate::state::bump_session_generation();
5900 out
5901 }
5902 }
5903
5904 struct SwapMidPublish {
5907 inner: MemoryRelay,
5908 }
5909 #[async_trait::async_trait]
5910 impl Transport for SwapMidPublish {
5911 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5912 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
5913 self.inner.publish(e, r).await
5914 }
5915 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5916 let out = self.inner.publish_durable(e, r).await;
5917 crate::state::bump_session_generation();
5918 out
5919 }
5920 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
5921 self.inner.fetch(q, r).await
5922 }
5923 }
5924
5925 struct FixedFetch {
5930 events: Vec<Event>,
5931 }
5932 #[async_trait::async_trait]
5933 impl Transport for FixedFetch {
5934 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5935 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
5936 Ok(())
5937 }
5938 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
5939 Ok(())
5940 }
5941 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
5942 Ok(self.events.clone())
5943 }
5944 }
5945
5946 async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
5949 let q = Query {
5950 kinds: vec![stream::KIND_WRAP],
5951 p_tags: vec![me.to_hex()],
5952 k_tags: vec!["3313".to_string()],
5953 ..Default::default()
5954 };
5955 relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
5956 }
5957
5958 #[tokio::test]
5959 async fn create_persists_and_reloads_a_v2_community() {
5960 let (_tmp, _guard, owner) = init_test_db();
5961 let relay = MemoryRelay::new();
5962 let relays = vec!["wss://r".to_string()];
5963
5964 let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
5965 assert!(created.identity.verify());
5966 assert_eq!(created.owner().unwrap(), owner.public_key());
5967 assert_eq!(created.channels.len(), 1);
5968
5969 assert_eq!(
5971 crate::db::community::community_protocol(created.id()).unwrap(),
5972 Some(crate::community::ConcordProtocol::V2)
5973 );
5974 let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
5975 assert_eq!(loaded.name, "Vectorville");
5976 assert_eq!(loaded.community_root, created.community_root);
5977 assert_eq!(loaded.identity, created.identity);
5978 assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
5979 assert!(!loaded.channels[0].private);
5980
5981 assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
5983 }
5984
5985 #[tokio::test]
5986 async fn owner_sends_and_reads_back_a_message() {
5987 let (_tmp, _guard, _owner) = init_test_db();
5988 let relay = MemoryRelay::new();
5989 let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
5990 let general = community.channels[0].id;
5991
5992 let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
5993 let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
5994 assert_ne!(id1, id2);
5995
5996 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
5997 let texts: Vec<String> = page
5998 .iter()
5999 .filter_map(|f| match &f.event {
6000 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
6001 _ => None,
6002 })
6003 .collect();
6004 assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
6005 }
6006
6007 #[tokio::test]
6008 async fn a_second_member_reads_the_public_channel_from_the_root() {
6009 let (_tmp, _guard, _owner) = init_test_db();
6013 let relay = MemoryRelay::new();
6014 let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
6015 let general = community.channels[0].id;
6016 send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
6017
6018 let member_view = community.clone();
6020 let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
6021 assert_eq!(page.len(), 1);
6022 assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
6023 assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
6024 }
6025
6026 async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
6029 fetch_channel(relay, community, channel, 100)
6030 .await
6031 .unwrap()
6032 .iter()
6033 .filter_map(|f| match &f.event {
6034 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
6035 _ => None,
6036 })
6037 .collect()
6038 }
6039
6040 #[tokio::test]
6041 async fn direct_invite_full_loop_owner_and_member_converse() {
6042 let (bed, owner, member) = TestBed::new();
6043
6044 bed.swap_to(&owner);
6046 let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
6047 let general = community.channels[0].id;
6048 send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
6049 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6050
6051 bed.swap_to(&member);
6053 assert!(
6054 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6055 "the member does not hold the community before joining"
6056 );
6057 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6058 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6059 assert_eq!(joined.id().0, community.id().0, "joined the same community");
6060 assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
6061 assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
6062
6063 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
6065 send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
6066
6067 bed.swap_to(&owner);
6069 assert_eq!(
6070 texts_in(&bed.relay, &community, &general).await,
6071 vec!["owner: welcome!", "member: thanks for the invite"],
6072 "both actors' messages interleave in ms order on the shared channel"
6073 );
6074
6075 let members = memberlist(&bed.relay, &community).await.unwrap();
6077 assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
6078 assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
6079 assert_eq!(members.len(), 2);
6080 }
6081
6082 #[tokio::test]
6086 async fn a_banned_member_is_refused_at_join_time() {
6087 let (bed, owner, member) = TestBed::new();
6088
6089 bed.swap_to(&owner);
6090 let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
6091 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6092 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
6093
6094 bed.swap_to(&member);
6095 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6096 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6097 assert!(err.contains("banned"), "refusal names the reason: {err}");
6098 assert!(
6099 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6100 "a refused join persists nothing"
6101 );
6102
6103 bed.swap_to(&owner);
6106 set_banlist(&bed.relay, &community, &[]).await.unwrap();
6107 bed.swap_to(&member);
6108 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6109 assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
6110 }
6111
6112 #[tokio::test]
6116 async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
6117 use crate::community::migration;
6118 let (bed, owner, member) = TestBed::new();
6119
6120 bed.swap_to(&owner);
6122 let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
6123 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
6124 let jm = join_material(&v2);
6125
6126 bed.swap_to(&member);
6129 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6130 let v1_cid = v1.id.to_hex();
6131 v1.owner_attestation = Some({
6132 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6133 .finalize(&owner.keys).unwrap().as_json()
6134 });
6135 crate::db::community::save_community(&v1).unwrap();
6136 let v1_channel = v1.channels[0].id.to_hex();
6137
6138 let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
6140 let signpost = migration::MigrationSignpost {
6141 v2_community_id: v2_hex.clone(),
6142 owner_xonly: owner.keys.public_key().to_hex(),
6143 owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
6144 relays: bed.relays.clone(),
6145 name: "Guild".into(),
6146 primary_channel: v1_channel.clone(),
6147 root_epoch: 0,
6148 };
6149 let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
6150 crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
6151
6152 let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
6154 assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
6155
6156 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
6158 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
6159 assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
6160 let _ = v1_channel;
6161
6162 assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
6164 }
6165
6166 #[tokio::test]
6171 async fn owner_wizard_then_member_migrate_and_stitch() {
6172 use crate::community::migration;
6173 let (bed, owner, member) = TestBed::new();
6174 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6175
6176 bed.swap_to(&owner);
6178 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6179 let v1_cid = v1.id.to_hex();
6180 let v1_channel = v1.channels[0].id.to_hex();
6181 v1.owner_attestation = Some({
6182 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6183 .finalize(&owner.keys).unwrap().as_json()
6184 });
6185 crate::db::community::save_community(&v1).unwrap();
6186
6187 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6189 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
6190 "owner's own client flipped to v2");
6191 assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
6194 "owner channel stitched to v2");
6195
6196 bed.swap_to(&member);
6198 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6199 m_v1.id = v1.id;
6201 m_v1.server_root_key = v1.server_root_key.clone();
6202 m_v1.channels[0].id = v1.channels[0].id;
6203 m_v1.owner_attestation = v1.owner_attestation.clone();
6204 crate::db::community::save_community(&m_v1).unwrap();
6205
6206 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
6209 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
6210 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
6211 "the FOLD ITSELF flipped the member (auto-drive)");
6212 assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
6213 "member holds the v2 twin");
6214 assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
6216 }
6217
6218 #[tokio::test]
6223 async fn wizard_publishes_the_twin_to_the_cross_device_list() {
6224 use crate::community::migration;
6225 let (bed, owner, _member) = TestBed::new();
6226 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6227
6228 bed.swap_to(&owner);
6229 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6230 let v1_cid = v1.id.to_hex();
6231 v1.owner_attestation = Some({
6232 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6233 .finalize(&owner.keys).unwrap().as_json()
6234 });
6235 crate::db::community::save_community(&v1).unwrap();
6236
6237 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6238
6239 let list = fetch_community_list(&bed.relay, &bed.relays).await.unwrap()
6241 .expect("the wizard published a community list");
6242 assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
6243 assert!(
6247 !list.tombstones.iter().any(|t| t.community_id == v1_cid),
6248 "migration must not tombstone the v1 community"
6249 );
6250 }
6251
6252 #[tokio::test]
6257 async fn wizard_refuses_while_a_drive_holds_the_claim() {
6258 use crate::community::migration;
6259 let (bed, owner, _member) = TestBed::new();
6260 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6261
6262 bed.swap_to(&owner);
6263 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6264 let v1_cid = v1.id.to_hex();
6265 v1.owner_attestation = Some({
6266 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6267 .finalize(&owner.keys).unwrap().as_json()
6268 });
6269 crate::db::community::save_community(&v1).unwrap();
6270
6271 migration::test_hold_drive_claim(&v1_cid);
6273 let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
6274 assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
6275 assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
6277 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
6278
6279 migration::test_release_drive_claim(&v1_cid);
6281 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6282 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
6283 }
6284
6285 #[tokio::test]
6291 async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
6292 use crate::community::migration;
6293 let (bed, owner, _member) = TestBed::new();
6294 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6295 let relay = std::sync::Arc::new(MemoryRelay::new());
6297
6298 bed.swap_to(&owner);
6299 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6300 let v1_cid = v1.id.to_hex();
6301 let v1_channel = v1.channels[0].id.to_hex();
6302 v1.owner_attestation = Some({
6303 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6304 .finalize(&owner.keys).unwrap().as_json()
6305 });
6306 crate::db::community::save_community(&v1).unwrap();
6307
6308 let twin = create_migration_twin(
6311 &*relay, "Guild", bed.relays.clone(), None,
6312 (v1.channels[0].id, "general".to_string()),
6313 ).await.unwrap();
6314 let v2_id = twin.identity.community_id;
6315 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
6316 crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
6317
6318 let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
6320
6321 let wizard = tokio::spawn({
6322 let relay = relay.clone();
6323 let v1 = v1.clone();
6324 async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
6325 });
6326
6327 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
6329 assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
6330 assert!(
6331 crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
6332 "the fence must not be stamped while the follow lock is held"
6333 );
6334
6335 drop(held);
6337 let flipped = wizard.await.unwrap().unwrap();
6338 assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
6339 assert_eq!(
6340 crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
6341 Some(v2_hex.as_str()),
6342 "the channel row is stitched to the twin, not pruned"
6343 );
6344 }
6345
6346 #[tokio::test]
6351 async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
6352 use crate::community::migration;
6353 let (bed, owner, banned) = TestBed::new();
6354 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6355
6356 bed.swap_to(&owner);
6358 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6359 let v1_cid = v1.id.to_hex();
6360 v1.owner_attestation = Some({
6361 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6362 .finalize(&owner.keys).unwrap().as_json()
6363 });
6364 crate::db::community::save_community(&v1).unwrap();
6365 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
6366
6367 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6368
6369 bed.swap_to(&banned);
6371 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6372 m_v1.id = v1.id;
6373 m_v1.server_root_key = v1.server_root_key.clone();
6374 m_v1.channels[0].id = v1.channels[0].id;
6375 m_v1.owner_attestation = v1.owner_attestation.clone();
6376 crate::db::community::save_community(&m_v1).unwrap();
6377 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
6378
6379 let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
6381 let payload = migration::parse_migration_payload(&raw).unwrap();
6382 assert!(payload.m.is_some());
6383 let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
6384 assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
6385 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
6386 assert!(
6387 crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
6388 "banned member never acquires the v2 twin"
6389 );
6390 }
6391
6392 #[tokio::test]
6397 async fn wizard_resume_continues_on_the_same_twin() {
6398 use crate::community::migration;
6399 let (bed, owner, banned) = TestBed::new();
6400 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6401
6402 bed.swap_to(&owner);
6403 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6404 let mut sibling = v1.channels[0].clone();
6406 sibling.id = crate::community::ChannelId(crate::community::random_32());
6407 sibling.name = "offtopic".into();
6408 v1.channels.push(sibling.clone());
6409 let v1_cid = v1.id.to_hex();
6410 v1.owner_attestation = Some({
6411 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6412 .finalize(&owner.keys).unwrap().as_json()
6413 });
6414 crate::db::community::save_community(&v1).unwrap();
6415 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
6416
6417 let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
6420 let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
6421 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
6422
6423 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6425 assert_eq!(v2_hex, minted_hex, "no second twin was minted");
6426 let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
6427 assert_eq!(ledger_v2, minted_hex);
6428 assert_eq!(phase, migration::PHASE_FLIPPED);
6429 assert_eq!(
6432 crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
6433 Some(minted_hex.as_str()),
6434 "sibling channel re-parented by the resumed run"
6435 );
6436 let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
6437 let (_, _, wire_banlist) = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
6438 .await
6439 .map(|(c, h, b)| (c, h, b))
6440 .unwrap();
6441 assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
6442 "the resumed banlist clone is folded from the twin's wire control plane");
6443
6444 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
6447 let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6448 assert_eq!(healed, minted_hex);
6449 let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
6450 assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
6451
6452 let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
6458 let v1b_cid = v1b.id.to_hex();
6459 v1b.owner_attestation = Some({
6460 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
6461 .finalize(&owner.keys).unwrap().as_json()
6462 });
6463 crate::db::community::save_community(&v1b).unwrap();
6464 let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
6465 let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
6466 crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
6467 crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
6469 assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
6470 assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
6471 }
6472
6473 #[tokio::test]
6478 async fn birth_refound_seeds_an_explicit_roster() {
6479 let (bed, owner, _m) = TestBed::new();
6480 bed.swap_to(&owner);
6481 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
6482 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
6483 assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
6484 let ghost_a = Keys::generate().public_key();
6486 let ghost_b = Keys::generate().public_key();
6487
6488 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
6489 assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
6490
6491 let members = memberlist(&bed.relay, &rolled).await.unwrap();
6494 assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
6495 assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
6496
6497 let (_, _, _banlist) = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
6501 .expect("the epoch-1 twin verifies from its compacted control plane");
6502
6503 let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
6506 assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
6507 assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
6508 }
6509
6510 #[tokio::test]
6514 async fn birth_refound_ignores_a_banned_seed_entry() {
6515 let (bed, owner, _m) = TestBed::new();
6516 bed.swap_to(&owner);
6517 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
6518 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
6519 let good = Keys::generate().public_key();
6520 let banned = Keys::generate();
6521 set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
6523 let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
6524
6525 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
6526 .expect("a banned seed entry is filtered, not a permanent verify-back wedge");
6527 assert_eq!(rolled.root_epoch, Epoch(1));
6528 let members = memberlist(&bed.relay, &rolled).await.unwrap();
6529 assert!(members.contains(&good), "the non-banned seed lands");
6530 assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
6531 }
6532
6533 #[tokio::test]
6539 async fn a_seeded_member_receives_a_later_refound_rekey() {
6540 let (bed, owner, _m) = TestBed::new();
6541 bed.swap_to(&owner);
6542 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
6543 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
6544 let ghost = Keys::generate();
6545 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
6547 assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
6548
6549 let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
6555 assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
6556 assert!(
6557 memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
6558 "a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
6559 );
6560 }
6561
6562 #[tokio::test]
6565 async fn v1_admin_stays_admin_across_migration() {
6566 use crate::community::migration;
6567 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6568 let (bed, owner, admin) = TestBed::new();
6569 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6570
6571 bed.swap_to(&owner);
6572 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6573 let v1_cid = v1.id.to_hex();
6574 v1.owner_attestation = Some({
6575 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6576 .finalize(&owner.keys).unwrap().as_json()
6577 });
6578 crate::db::community::save_community(&v1).unwrap();
6579 let admin_role = Role::admin("a1".repeat(32));
6581 let roles = CommunityRoles {
6582 roles: vec![admin_role.clone()],
6583 grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
6584 };
6585 crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
6586
6587 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6588 let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
6589
6590 let authority = fetch_authority(&bed.relay, &twin).await;
6592 assert!(
6593 authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
6594 "the v1 admin is an admin on the v2 twin"
6595 );
6596 assert!(
6597 !authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
6598 "a non-admin gains no authority"
6599 );
6600 }
6601
6602 #[tokio::test]
6607 async fn sweep_marks_checked_only_on_an_owner_tombstone() {
6608 use crate::community::migration;
6609 let (bed, owner, stranger) = TestBed::new();
6610
6611 bed.swap_to(&owner);
6612 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6613 let v1_cid = v1.id.to_hex();
6614 v1.owner_attestation = Some({
6615 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6616 .finalize(&owner.keys).unwrap().as_json()
6617 });
6618 crate::db::community::save_community(&v1).unwrap();
6619
6620 let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
6623 let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
6624 bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
6625 crate::db::community::set_community_dissolved(&v1_cid).unwrap();
6626
6627 migration::sweep_dissolved_for_migration(&bed.relay).await;
6629 assert!(
6630 crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
6631 "a stranger-only probe must not converge the sweep"
6632 );
6633
6634 let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
6636 let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
6637 bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
6638 migration::sweep_dissolved_for_migration(&bed.relay).await;
6639 assert!(
6640 !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
6641 "an owner plain-dissolution converges the sweep"
6642 );
6643 }
6644
6645 #[tokio::test]
6647 async fn wizard_preflight_gates_timelock_and_ownership() {
6648 use crate::community::migration;
6649 let (bed, owner, _member) = TestBed::new();
6650 bed.swap_to(&owner);
6651 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6652 v1.owner_attestation = Some({
6653 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
6654 .finalize(&owner.keys).unwrap().as_json()
6655 });
6656 crate::db::community::save_community(&v1).unwrap();
6657
6658 let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
6660 assert!(err.contains("not unlocked"), "{err}");
6661 assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
6662 }
6663
6664 #[tokio::test]
6665 async fn public_link_full_loop() {
6666 let (bed, owner, member) = TestBed::new();
6667
6668 bed.swap_to(&owner);
6669 let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
6670 let general = community.channels[0].id;
6671 send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
6672 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6674 assert!(link.url.starts_with("https://vectorapp.io/invite/"));
6675 assert!(link.url.contains('#'), "the fragment carries the token");
6676
6677 bed.swap_to(&member);
6679 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
6680 assert_eq!(joined.id().0, community.id().0);
6681 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
6682 }
6683
6684 #[test]
6685 fn bundle_of_snapshots_the_held_icon() {
6686 let owner = Keys::generate();
6687 let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
6688 let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
6689 let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
6690 c.icon = Some(icon.clone());
6691 let bundle = bundle_of(&c, BundleAudience::Link, None, None, None);
6692 assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
6693 }
6694
6695 #[test]
6696 fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
6697 let (_tmp, _guard, _owner) = init_test_db();
6702 let cur_root = [9u8; 32];
6703 let cid = crate::community::CommunityId([1u8; 32]);
6704 let cid_hex = cid.to_hex();
6705
6706 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
6708 assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
6709
6710 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
6712 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
6713 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
6714 assert_eq!(roots[0], cur_root, "current root leads");
6715 assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
6716 assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
6717 assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
6719
6720 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
6722 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
6723 assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
6724
6725 for e in 3..20u64 {
6727 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
6728 }
6729 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
6730 assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
6731 }
6732
6733 #[tokio::test]
6734 async fn public_link_preview_shows_live_name_and_icon_without_joining() {
6735 let (bed, owner, member) = TestBed::new();
6736
6737 bed.swap_to(&owner);
6738 let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
6739 let icon = control::ImageRef {
6742 url: "https://blossom.example/soap".into(),
6743 key: "k".into(),
6744 nonce: "n".into(),
6745 hash: "h".into(),
6746 extra: Default::default(),
6747 };
6748 let mut meta = community.metadata();
6749 meta.icon = Some(icon.clone());
6750 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
6751 let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
6753
6754 bed.swap_to(&member);
6756 let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
6757 assert_eq!(preview.name, "Soapbox");
6758 assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
6759 assert!(
6760 crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
6761 "previewing must not persist a membership"
6762 );
6763 }
6764
6765 #[tokio::test]
6766 async fn a_previewed_join_reuses_the_verified_fold() {
6767 let (bed, owner, member) = TestBed::new();
6768 bed.swap_to(&owner);
6769 let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
6770 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6771
6772 bed.swap_to(&member);
6773 let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
6774 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
6775 assert_eq!(joined.id().0, community.id().0);
6776 assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
6777 assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
6780 }
6781
6782 #[tokio::test]
6783 async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
6784 let (bed, owner, member) = TestBed::new();
6785 bed.swap_to(&owner);
6786 let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
6787 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6788
6789 bed.swap_to(&member);
6790 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
6791
6792 let session = SessionGuard::capture();
6794 assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the seed folds fresh events");
6795 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
6796 let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
6797 assert!(cursor > 0, "the cursor advanced past zero");
6798 let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
6799 let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
6800 assert_eq!(stored, live, "stored fold == live fold after the seed");
6801 assert!(stored.contains(&member.keys.public_key()));
6802
6803 assert!(sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty());
6805
6806 bed.swap_to(&owner);
6808 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
6809 bed.swap_to(&member);
6810 let session = SessionGuard::capture();
6811 assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the kick lands incrementally");
6812 assert!(
6813 !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
6814 "an owner kick removes the member from the stored fold"
6815 );
6816 }
6817
6818 #[tokio::test]
6819 async fn a_preview_then_revoke_still_refuses_the_join() {
6820 let (bed, owner, member) = TestBed::new();
6821 bed.swap_to(&owner);
6822 let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
6823 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6824
6825 bed.swap_to(&member);
6827 let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
6828 assert_eq!(p.name, "RevokeRace");
6829 bed.swap_to(&owner);
6830 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6831 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
6832
6833 bed.swap_to(&member);
6836 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
6837 assert!(err.contains("revoked"), "got: {err}");
6838 }
6839
6840 #[tokio::test]
6841 async fn a_revoked_link_refuses_to_join() {
6842 let (bed, owner, member) = TestBed::new();
6843 bed.swap_to(&owner);
6844 let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
6845 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6846 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6848 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
6849
6850 bed.swap_to(&member);
6851 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
6852 assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
6853 }
6854
6855 #[tokio::test]
6856 async fn an_expired_direct_invite_refuses_to_join() {
6857 let (bed, owner, member) = TestBed::new();
6858 bed.swap_to(&owner);
6859 let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
6860 let inviter = owner.keys.clone();
6862 let mut bundle = bundle_of(&community, BundleAudience::Link, Some(inviter.public_key()), Some(1_000), None);
6863 bundle.expires_at = Some(1_000); let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
6865 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
6866
6867 bed.swap_to(&member);
6868 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6869 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6870 assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
6871 }
6872
6873 #[tokio::test]
6874 async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
6875 let (bed, owner, member) = TestBed::new();
6879 bed.swap_to(&owner);
6880 let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
6881 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6882 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6883
6884 let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
6887
6888 bed.swap_to(&member);
6889 let err = accept_public_link(&union, &link.url).await.unwrap_err();
6890 assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
6891 }
6892
6893 #[test]
6894 fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
6895 let owner = Keys::generate();
6901 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
6902 let hex = crate::simd::hex::bytes_to_hex_32;
6903 let root = [0x11u8; 32];
6904 let mut bundle = CommunityInvite {
6905 community_id: hex(&identity.community_id.0),
6906 owner: hex(&identity.owner_xonly),
6907 owner_salt: hex(&identity.owner_salt),
6908 community_root: hex(&root),
6909 root_epoch: 0,
6910 channels: vec![],
6911 relays: vec!["wss://r".into()],
6912 name: "X".into(),
6913 icon: None,
6914 expires_at: None,
6915 creator_npub: None,
6916 label: None,
6917 extra: Default::default(),
6918 };
6919 bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
6920 .map(|i| {
6921 let mut id = [0u8; 32];
6922 id[..8].copy_from_slice(&(i as u64).to_be_bytes());
6923 invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
6924 })
6925 .collect();
6926 assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
6927 }
6928
6929 #[tokio::test]
6930 async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
6931 let (bed, owner, member) = TestBed::new();
6936 bed.swap_to(&owner);
6937 let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
6938 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6939 let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
6941 swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
6942
6943 bed.swap_to(&member);
6944 let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
6945 assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
6946 assert!(
6947 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6948 "the aborted join wrote nothing to the (member) account DB"
6949 );
6950 }
6951
6952 #[tokio::test]
6953 async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
6954 let (_tmp, _guard, owner) = init_test_db();
6957 let relay = MemoryRelay::new();
6958 let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
6959 let empty = MemoryRelay::new();
6962 let members = memberlist(&empty, &community).await.unwrap();
6963 assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
6964 }
6965
6966 #[tokio::test]
6967 async fn an_expiring_minted_invite_refuses_after_the_deadline() {
6968 let (bed, owner, member) = TestBed::new();
6971 bed.swap_to(&owner);
6972 let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
6973 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
6974 .await
6975 .unwrap();
6976
6977 bed.swap_to(&member);
6978 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6979 assert!(
6980 accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
6981 "a minted expiring invite refuses past its deadline"
6982 );
6983 }
6984
6985 #[tokio::test]
6986 async fn a_member_who_leaves_drops_from_the_memberlist() {
6987 let (bed, owner, member) = TestBed::new();
6988 bed.swap_to(&owner);
6989 let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
6990 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6991
6992 bed.swap_to(&member);
6993 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6994 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6995 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
6997 leave_community(&bed.relay, &joined).await.unwrap();
6998
6999 bed.swap_to(&owner);
7000 let members = memberlist(&bed.relay, &community).await.unwrap();
7001 assert!(members.contains(&owner.keys.public_key()));
7002 assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
7003 }
7004
7005 #[tokio::test]
7006 async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
7007 let (bed, owner, member) = TestBed::new();
7010 bed.swap_to(&owner);
7011 let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
7012 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
7013
7014 bed.swap_to(&member);
7015 assert!(
7016 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
7017 "the owner's community must be invisible in the member's account DB"
7018 );
7019 assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
7020 }
7021
7022 async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
7030 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7031 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
7032 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
7033 let mut head: Option<(u64, [u8; 32])> = None;
7034 for w in &wraps {
7035 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
7036 if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
7037 head = Some((ed.version, ed.self_hash));
7038 }
7039 }
7040 }
7041 head.map(|(_, h)| h)
7042 }
7043
7044 async fn cite_on_relay(
7049 relay: &MemoryRelay,
7050 community: &CommunityV2,
7051 signer: &Keys,
7052 ) -> Option<crate::community::edition::AuthorityCitation> {
7053 if community.owner().ok() == Some(signer.public_key()) {
7054 return None;
7055 }
7056 let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
7057 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7058 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
7059 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
7060 let mut head: Option<(u64, [u8; 32])> = None;
7061 for w in &wraps {
7062 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
7063 if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
7064 head = Some((ed.version, ed.self_hash));
7065 }
7066 }
7067 }
7068 head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
7069 }
7070
7071 async fn publish_channel_edition(
7072 relay: &MemoryRelay,
7073 community: &CommunityV2,
7074 signer: &Keys,
7075 channel_id: &ChannelId,
7076 name: &str,
7077 private: bool,
7078 version: u64,
7079 deleted: bool,
7080 ) {
7081 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7082 let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
7083 let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
7084 let content = serde_json::to_string(&meta).unwrap();
7085 let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
7086 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7087 relay.publish(&wrap, &community.relays).await.unwrap();
7088 }
7089
7090 async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
7093 publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
7094 }
7095
7096 async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
7099 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7100 let prev = head_hash_on_relay(relay, community, &community.id().0).await;
7101 let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
7102 let content = serde_json::to_string(&meta).unwrap();
7103 let cite = cite_on_relay(relay, community, signer).await;
7104 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());
7105 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
7106 relay.publish(&wrap, &community.relays).await.unwrap();
7107 }
7108
7109 #[test]
7110 fn metadata_apply_captures_undriven_fields_for_republish() {
7111 let owner = Keys::generate();
7112 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
7113 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
7114 let general = held.channels[0].id;
7115
7116 let mut custom = serde_json::Map::new();
7118 custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
7119 let mut extra = serde_json::Map::new();
7120 extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
7121 let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
7122 assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
7123 assert_eq!(held.meta_custom, Some(custom.clone()));
7124 assert_eq!(held.meta_extra, extra);
7125 assert_eq!(held.metadata().custom, Some(custom));
7127 assert_eq!(held.metadata().extra, held.meta_extra);
7128
7129 let mut ch_custom = serde_json::Map::new();
7131 ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
7132 let ch_meta = control::ChannelMetadata {
7133 name: "general".into(),
7134 private: false,
7135 voice: Some(true),
7136 deleted: None,
7137 custom: Some(ch_custom.clone()),
7138 extra: Default::default(),
7139 };
7140 assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
7141 let ch = held.channel(&general).unwrap();
7142 assert_eq!(ch.voice, Some(true));
7143 assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
7144 let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
7145 assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
7146 assert_eq!(rename.custom, Some(ch_custom));
7147 }
7148
7149 #[test]
7150 fn community_metadata_apply_sets_and_clears_images() {
7151 let owner = Keys::generate();
7152 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
7153 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
7154
7155 let icon = control::ImageRef {
7156 url: "https://blossom.example/i".into(),
7157 key: "k".into(),
7158 nonce: "n".into(),
7159 hash: "h".into(),
7160 extra: Default::default(),
7161 };
7162 let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
7163 assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
7164 assert_eq!(held.icon.as_ref(), Some(&icon));
7165
7166 let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
7168 assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
7169 assert_eq!(held.icon, None);
7170 }
7171
7172 async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
7174 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7175 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
7176 let prev = head_hash_on_relay(relay, community, &role_id).await;
7177 let content = crate::community::v2::roles::role_content_json(role).unwrap();
7178 let cite = cite_on_relay(relay, community, signer).await;
7179 let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7180 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7181 relay.publish(&wrap, &community.relays).await.unwrap();
7182 }
7183
7184 async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
7186 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7187 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
7188 let prev = head_hash_on_relay(relay, community, &eid).await;
7189 let grant = MemberGrant { member: member.to_hex(), role_ids };
7190 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
7191 let cite = cite_on_relay(relay, community, signer).await;
7192 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7193 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7194 relay.publish(&wrap, &community.relays).await.unwrap();
7195 }
7196
7197 async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
7199 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7200 let eid = crate::community::v2::derive::banlist_locator(community.id());
7201 let prev = head_hash_on_relay(relay, community, &eid).await;
7202 let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
7203 let cite = cite_on_relay(relay, community, signer).await;
7204 let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7205 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7206 relay.publish(&wrap, &community.relays).await.unwrap();
7207 }
7208
7209 fn admin_role(role_id: &str, perms: u64) -> Role {
7210 Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
7211 }
7212
7213 #[tokio::test]
7219 async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
7220 let (bed, owner, attacker) = TestBed::new();
7221 bed.swap_to(&owner);
7222 let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
7223 let victim = Keys::generate().public_key();
7224 grant_admin(&bed.relay, &community, &victim).await.unwrap();
7225
7226 let admin_rid = fetch_authority(&bed.relay, &community)
7228 .await
7229 .roles
7230 .roles
7231 .iter()
7232 .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
7233 .unwrap()
7234 .role_id
7235 .clone();
7236 publish_role(
7238 &bed.relay,
7239 &community,
7240 &attacker.keys,
7241 &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
7242 2,
7243 )
7244 .await;
7245
7246 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
7247 assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
7248 assert!(
7249 authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
7250 "the floor advances only to the AUTHORIZED head (owner v1)"
7251 );
7252 assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
7253 }
7254
7255 #[tokio::test]
7256 async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
7257 let (bed, owner, attacker) = TestBed::new();
7258 bed.swap_to(&owner);
7259 let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
7260 let victim = Keys::generate();
7261 grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
7262
7263 publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
7265
7266 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
7267 assert!(
7268 authority.roles.is_admin(&victim.public_key().to_hex()),
7269 "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
7270 );
7271 }
7272
7273 #[tokio::test]
7274 async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
7275 let (bed, owner, attacker) = TestBed::new();
7276 bed.swap_to(&owner);
7277 let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
7278 let victim = Keys::generate().public_key();
7279 grant_admin(&bed.relay, &community, &victim).await.unwrap();
7280
7281 for i in 0u8..6 {
7284 let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
7285 publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7286 }
7287
7288 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
7289 assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
7290 assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
7291 }
7292
7293 fn authority_fingerprint(a: &AuthoritySet) -> String {
7296 let mut roles = a.roles.roles.clone();
7297 roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
7298 let mut grants = a.roles.grants.clone();
7299 for g in &mut grants {
7300 g.role_ids.sort();
7301 }
7302 grants.sort_by(|x, y| x.member.cmp(&y.member));
7303 let banned: Vec<&String> = a.banned.iter().collect();
7304 serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
7305 }
7306
7307 #[tokio::test]
7308 async fn the_v2_authority_fold_is_order_independent() {
7309 let (bed, owner, _a) = TestBed::new();
7315 bed.swap_to(&owner);
7316 let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
7317
7318 let admin1 = Keys::generate().public_key();
7321 let admin2 = Keys::generate().public_key();
7322 grant_admin(&bed.relay, &community, &admin1).await.unwrap();
7323 grant_admin(&bed.relay, &community, &admin2).await.unwrap();
7324 let mod_rid = "5c".repeat(32);
7325 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
7326 let member = Keys::generate().public_key();
7327 publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
7328 let banned_member = Keys::generate().public_key();
7329 publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
7330 set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
7331 let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
7332 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
7333 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
7334
7335 let editions = fetch_control(&bed.relay, &community).await;
7336 let floors = load_floors(&community);
7337 assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
7338
7339 let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
7340
7341 let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
7344 let mut rev = editions.clone();
7345 rev.reverse();
7346 orders.push(rev);
7347 for shift in [1usize, 3, 5, 7] {
7348 let n = editions.len();
7349 orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
7350 }
7351 let mut zip = Vec::with_capacity(editions.len());
7353 let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
7354 while lo <= hi {
7355 zip.push(editions[lo as usize].clone());
7356 if lo != hi {
7357 zip.push(editions[hi as usize].clone());
7358 }
7359 lo += 1;
7360 hi -= 1;
7361 }
7362 orders.push(zip);
7363
7364 for (i, order) in orders.iter().enumerate() {
7365 let got = authority_fingerprint(&fold_authority(&community, order, &floors));
7366 assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
7367 }
7368 assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
7371 assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
7372 }
7373
7374 struct FetchErrors(MemoryRelay);
7376 #[async_trait::async_trait]
7377 impl crate::community::transport::Transport for FetchErrors {
7378 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
7379 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
7380 self.0.publish(e, r).await
7381 }
7382 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
7383 Err("relay down".to_string())
7384 }
7385 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
7386 self.0.publish_durable(e, r).await
7387 }
7388 }
7389
7390 #[tokio::test]
7391 async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
7392 let (bed, owner, victim) = TestBed::new();
7393 bed.swap_to(&owner);
7394 let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
7395 let victim_hex = victim.keys.public_key().to_hex();
7396 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7398 crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
7399
7400 let down = FetchErrors(MemoryRelay::new());
7403 let view = fetch_authority(&down, &community).await;
7404 assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
7405 }
7406
7407 #[tokio::test]
7408 async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
7409 let (bed, owner, _m) = TestBed::new();
7410 bed.swap_to(&owner);
7411 let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
7412 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7413 let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
7414 let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
7415
7416 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7418 publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
7419 publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
7420 let session = crate::state::SessionGuard::capture();
7421 follow_control(&bed.relay, &community, &session).await.unwrap();
7422 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
7423
7424 let relay2 = MemoryRelay::new();
7428 publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
7429 follow_control(&relay2, &community, &session).await.unwrap();
7430 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7431 assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
7432 }
7433
7434 #[tokio::test]
7435 async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
7436 let (_tmp, _guard, owner) = init_test_db();
7441 let relay = MemoryRelay::new();
7442 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
7443 let admin = Keys::generate();
7444 let rid = "a7".repeat(32);
7445 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
7446 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
7447
7448 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7450 let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
7451 let rumor = control::build_edition_rumor(
7452 admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
7453 head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
7454 &serde_json::to_string(&meta).unwrap(), 1_000, None,
7455 );
7456 let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
7457 relay.publish(&wrap, &community.relays).await.unwrap();
7458
7459 let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
7460 let victim = Keys::generate().public_key().to_hex();
7461 let ban_rumor = control::build_edition_rumor(
7462 admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
7463 &serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
7464 );
7465 let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
7466 relay.publish(&ban_wrap, &community.relays).await.unwrap();
7467
7468 let session = SessionGuard::capture();
7469 let updated = follow_control(&relay, &community, &session).await.unwrap();
7470 assert!(
7471 updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
7472 "an uncited metadata edit must not be honored",
7473 );
7474 let authority = fetch_authority(&relay, &community).await;
7475 assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
7476 }
7483
7484 #[tokio::test]
7485 async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
7486 let (_tmp, _guard, owner) = init_test_db();
7490 let relay = MemoryRelay::new();
7491 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
7492 let admin = Keys::generate();
7493 let rid = "a1".repeat(32);
7494 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
7495 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
7496 publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
7497
7498 let session = SessionGuard::capture();
7499 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
7500 assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
7501
7502 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
7504 let _ = follow_control(&relay, &community, &session).await.unwrap();
7505 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7506 assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
7507 }
7508
7509 #[tokio::test]
7510 async fn a_roleless_member_cannot_edit_metadata() {
7511 let (_tmp, _guard, _owner) = init_test_db();
7512 let relay = MemoryRelay::new();
7513 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
7514 let stranger = Keys::generate();
7515 publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
7516 let session = SessionGuard::capture();
7517 assert!(
7518 follow_control(&relay, &community, &session).await.unwrap().is_none(),
7519 "a roleless member's metadata edit never folds"
7520 );
7521 }
7522
7523 #[tokio::test]
7524 async fn a_self_signed_grant_is_not_authority() {
7525 let (_tmp, _guard, _owner) = init_test_db();
7529 let relay = MemoryRelay::new();
7530 let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
7531 let rogue = Keys::generate();
7532 let rid = "b2".repeat(32);
7533 publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7534 publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
7535 publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
7536 let session = SessionGuard::capture();
7537 assert!(
7538 follow_control(&relay, &community, &session).await.unwrap().is_none(),
7539 "a self-signed grant confers no authority"
7540 );
7541 }
7542
7543 #[tokio::test]
7544 async fn the_banlist_is_enforced_only_from_a_ban_holder() {
7545 let (_tmp, _guard, owner) = init_test_db();
7546 let relay = MemoryRelay::new();
7547 let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
7548 let target = "cc".repeat(32);
7549
7550 let rogue = Keys::generate();
7552 publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
7553 let floors = load_floors(&community);
7554 let editions = fetch_control(&relay, &community).await;
7555 let authority = fold_authority(&community, &editions, &floors);
7556 assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
7557
7558 publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
7560 let editions = fetch_control(&relay, &community).await;
7561 let authority = fold_authority(&community, &editions, &floors);
7562 assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
7563 }
7564
7565 #[tokio::test]
7566 async fn a_banned_admin_loses_all_authority() {
7567 let (_tmp, _guard, owner) = init_test_db();
7570 let relay = MemoryRelay::new();
7571 let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
7572 let admin = Keys::generate();
7573 let rid = "e5".repeat(32);
7574 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
7575 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
7576 publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
7578
7579 let session = SessionGuard::capture();
7580 assert!(
7581 follow_control(&relay, &community, &session).await.unwrap().is_none(),
7582 "a banned admin's edit is dropped even with an unstripped grant"
7583 );
7584 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
7585 assert!(authority.banned.contains(&admin.public_key().to_hex()));
7586 assert!(
7587 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
7588 "a banned admin holds no bit"
7589 );
7590 }
7591
7592 #[tokio::test]
7593 async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
7594 let (_tmp, _guard, owner) = init_test_db();
7598 let relay = MemoryRelay::new();
7599 let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
7600 let admin = Keys::generate();
7601 let moder = Keys::generate();
7602 let stranger = Keys::generate();
7603 let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
7604 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;
7605 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;
7606 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
7607 publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
7608 publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
7609
7610 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
7611 assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
7612 assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
7613 assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
7614 }
7615
7616 #[tokio::test]
7617 async fn an_unauthorized_higher_banlist_cannot_unban() {
7618 let (_tmp, _guard, owner) = init_test_db();
7622 let relay = MemoryRelay::new();
7623 let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
7624 let target = "cc".repeat(32);
7625 publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
7626 let session = SessionGuard::capture();
7627 follow_control(&relay, &community, &session).await.unwrap(); let rogue = Keys::generate();
7630 publish_banlist(&relay, &community, &rogue, &[], 2).await; let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
7632 assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
7633 }
7634
7635 #[tokio::test]
7636 async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
7637 let (_tmp, _guard, _owner) = init_test_db();
7640 let relay = MemoryRelay::new();
7641 let relays = vec!["wss://r".to_string()];
7642 let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
7643 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
7644 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
7645
7646 let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
7647 assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
7648 assert_eq!(rehydrated[0].id().0, community.id().0);
7649 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
7650 }
7651
7652 #[tokio::test]
7653 async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
7654 let (_tmp, _guard, _owner) = init_test_db();
7655 let relay = MemoryRelay::new();
7656 let relays = vec!["wss://r".to_string()];
7657 let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
7658 leave_community(&relay, &community).await.unwrap(); let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
7661 assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
7662 }
7663
7664 #[tokio::test]
7665 async fn accepting_the_same_bundle_twice_is_idempotent() {
7666 let (bed, owner, member) = TestBed::new();
7670 bed.swap_to(&owner);
7671 let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
7672 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
7673 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7674 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
7675
7676 bed.swap_to(&member);
7677 let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
7678 let channels_after_first = first.channels.len();
7679 let root_after_first = first.community_root;
7680
7681 let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
7683 assert_eq!(second.id().0, first.id().0, "same community_id");
7684 assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
7685 assert_eq!(second.community_root, root_after_first, "root unchanged");
7686
7687 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7689 assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
7690 assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
7691 }
7692
7693 #[tokio::test]
7694 async fn a_severed_member_can_be_unbanned_and_re_admitted() {
7695 let (bed, owner, member) = TestBed::new();
7700 bed.swap_to(&owner);
7701 let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
7702 let general = community.channels[0].id;
7703 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
7704 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
7705
7706 bed.swap_to(&member);
7707 let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7708 let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
7709 assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
7710
7711 bed.swap_to(&owner);
7713 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
7714 grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
7715 community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
7716 assert_eq!(community.root_epoch, Epoch(1));
7717 send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
7718
7719 bed.swap_to(&member);
7721 let session = SessionGuard::capture();
7722 assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
7723
7724 bed.swap_to(&owner);
7728 set_banlist(&bed.relay, &community, &[]).await.unwrap();
7729 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7730 assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
7731 let fresh_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
7732
7733 bed.swap_to(&member);
7735 let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
7736 assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
7737 assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
7738 let seen = texts_in(&bed.relay, &rejoined, &general).await;
7739 assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
7740 send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
7741
7742 bed.swap_to(&owner);
7743 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7744 assert!(
7745 texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
7746 "the re-admitted member converses again at the new epoch"
7747 );
7748 let members = memberlist(&bed.relay, &community).await.unwrap();
7750 assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
7751 }
7752
7753 #[tokio::test]
7754 async fn dissolution_blocks_a_join() {
7755 let (bed, owner, member) = TestBed::new();
7758 bed.swap_to(&owner);
7759 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
7760 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7761 let bundle_json = serde_json::to_string(&bundle).unwrap();
7762 dissolve_community(&bed.relay, &community).await.unwrap();
7763 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
7764
7765 bed.swap_to(&member);
7766 let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
7767 assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
7768 }
7769
7770 #[tokio::test]
7771 async fn dissolution_seals_writes_but_not_reads() {
7772 let (bed, owner, _member) = TestBed::new();
7775 bed.swap_to(&owner);
7776 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
7777 let general = community.channels[0].id;
7778 send_message(&bed.relay, &community, &general, "before the end").await.unwrap();
7779
7780 dissolve_community(&bed.relay, &community).await.unwrap();
7781 let sealed = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7782
7783 for err in [
7784 send_message(&bed.relay, &sealed, &general, "after the end").await.unwrap_err(),
7785 send_reaction(&bed.relay, &sealed, &general, &"a".repeat(64), &"b".repeat(64), crate::community::v2::kind::MESSAGE, "+", None)
7786 .await
7787 .unwrap_err(),
7788 send_edit(&bed.relay, &sealed, &general, &"a".repeat(64), "revised").await.unwrap_err(),
7789 ] {
7790 assert!(err.contains("dissolved"), "every write is refused, got: {err}");
7791 }
7792 assert!(
7793 texts_in(&bed.relay, &sealed, &general).await.contains(&"before the end".to_string()),
7794 "but the history still reads"
7795 );
7796 }
7797
7798 #[tokio::test]
7799 async fn only_the_owner_can_dissolve() {
7800 let (bed, owner, member) = TestBed::new();
7801 bed.swap_to(&owner);
7802 let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
7803 bed.swap_to(&member);
7804 assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
7805 assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
7806 }
7807
7808 #[tokio::test]
7809 async fn a_foreign_tombstone_is_not_death() {
7810 let (_tmp, _guard, _owner) = init_test_db();
7813 let relay = MemoryRelay::new();
7814 let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
7815 let rogue = Keys::generate();
7816 let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
7817 let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
7818 relay.publish(&wrap, &community.relays).await.unwrap();
7819 assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
7820 }
7821
7822 #[tokio::test]
7823 async fn a_public_channel_reads_history_across_a_refounding() {
7824 let (_tmp, _guard, _owner) = init_test_db();
7828 let relay = MemoryRelay::new();
7829 let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
7830 let general = community.channels[0].id;
7831 send_message(&relay, &community, &general, "before the refounding").await.unwrap();
7832
7833 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
7834 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
7835 send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
7836
7837 let texts = texts_in(&relay, &refounded, &general).await;
7838 assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
7839 assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
7840 }
7841
7842 #[tokio::test]
7843 async fn refounding_aborts_when_control_state_is_withheld() {
7844 let (_tmp, _guard, owner) = init_test_db();
7848 let relay = MemoryRelay::new();
7849 let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
7850 publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
7851 let session = SessionGuard::capture();
7852 follow_control(&relay, &community, &session).await.unwrap(); let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7857 moved.relays = vec!["wss://empty".into()];
7858 crate::db::community::save_community_v2(&moved).unwrap();
7859
7860 let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
7861 assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
7862 assert_eq!(
7863 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
7864 Epoch(0),
7865 "the epoch did NOT advance (zero published state)"
7866 );
7867 }
7868
7869 #[tokio::test]
7870 async fn refounding_rolls_the_root_and_severs_a_removed_member() {
7871 let (bed, owner, member) = TestBed::new();
7874 bed.swap_to(&owner);
7875 let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
7876 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7877 let bundle_json = serde_json::to_string(&bundle).unwrap();
7878 bed.swap_to(&member);
7879 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7880
7881 bed.swap_to(&owner);
7882 let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
7883 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
7884 assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
7885 assert_eq!(
7887 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
7888 Epoch(1),
7889 "the owner committed the new epoch"
7890 );
7891
7892 bed.swap_to(&member);
7896 let session = SessionGuard::capture();
7897 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
7898 assert!(follow.self_removed, "the removed member is cut by the re-founding");
7899 }
7900
7901 #[tokio::test]
7902 async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
7903 let (bed, owner, member) = TestBed::new();
7908 bed.swap_to(&owner);
7909 let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
7910 let rid = "b0".repeat(32);
7911 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7912 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
7913 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7914 let bundle_json = serde_json::to_string(&bundle).unwrap();
7915 bed.swap_to(&member);
7916 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7917 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7920 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7921 assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
7923 assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
7925 }
7926
7927 #[tokio::test]
7928 async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
7929 let (bed, owner, me) = TestBed::new();
7934 let admin = Keys::generate();
7935 bed.swap_to(&owner);
7936 let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
7937 let rid = "b0".repeat(32);
7938 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7939 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
7940
7941 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7943 let bundle_json = serde_json::to_string(&bundle).unwrap();
7944 bed.swap_to(&me);
7945 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7946 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7947 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7948
7949 let new_root = [0xC7; 32];
7952 publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
7953
7954 let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
7955 .expect("an authorized admin's Refounding is adopted");
7956 assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
7957 assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
7958 }
7959
7960 #[tokio::test]
7961 async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
7962 let (bed, owner, me) = TestBed::new();
7970 bed.swap_to(&owner);
7971 let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
7972 let rid = "b1".repeat(32);
7973 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7974 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
7975
7976 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7977 let bundle_json = serde_json::to_string(&bundle).unwrap();
7978 bed.swap_to(&me);
7979 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7980 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7981 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7982
7983 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
7985 let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
7986 assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
7987
7988 let new_root = [0xD4; 32];
7991 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
7992
7993 let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
7994 .expect("the owner's Refounding is adopted");
7995 assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
7996
7997 let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
7998 assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
7999 assert_eq!(
8000 crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
8001 new_root,
8002 "and the new root behind the same URL",
8003 );
8004 }
8005
8006 #[tokio::test]
8007 async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
8008 let (bed, owner, me) = TestBed::new();
8012 let admin = Keys::generate();
8013 bed.swap_to(&owner);
8014 let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
8015 let rid = "b0".repeat(32);
8016 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8017 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
8018
8019 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8020 let bundle_json = serde_json::to_string(&bundle).unwrap();
8021 bed.swap_to(&me);
8022 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8023 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8024 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8025
8026 publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
8028 let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8029 assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
8030 }
8031
8032 #[tokio::test]
8033 async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
8034 let (bed, owner, me) = TestBed::new();
8038 let admin_a = Keys::generate();
8039 let admin_b = Keys::generate(); bed.swap_to(&owner);
8041 let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
8042 let rid = "b0".repeat(32);
8043 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8044 publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
8046 publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
8047
8048 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8049 let bundle_json = serde_json::to_string(&bundle).unwrap();
8050 bed.swap_to(&me);
8051 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8052 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8053 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8054
8055 publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
8057
8058 let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8059 assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
8060 }
8061
8062 #[tokio::test]
8063 async fn a_retried_refounding_reuses_the_same_root() {
8064 let (_tmp, _guard, _owner) = init_test_db();
8067 let relay = MemoryRelay::new();
8068 let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
8069 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8070 let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
8071 let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
8072 assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
8073 }
8074
8075 #[tokio::test]
8076 async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
8077 let (bed, owner, attacker) = TestBed::new();
8084 bed.swap_to(&owner);
8085 let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
8086
8087 let senior = "a1".repeat(32);
8089 let mid = "a5".repeat(32);
8090 publish_role(&bed.relay, &community, &owner.keys,
8091 &Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
8092 publish_role(&bed.relay, &community, &owner.keys,
8093 &Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0 }, 1).await;
8094 publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
8095
8096 publish_role(&bed.relay, &community, &attacker.keys,
8098 &Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 2).await;
8099
8100 let authority = fetch_authority(&bed.relay, &community).await;
8101 let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
8102 assert_eq!(
8103 folded_senior.position, 1,
8104 "a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
8105 );
8106 }
8107
8108 #[tokio::test]
8109 async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
8110 let (bed, owner, admin) = TestBed::new();
8117 bed.swap_to(&owner);
8118 let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
8119 let rid = "c1".repeat(32);
8120 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
8121 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
8122
8123 let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
8125 edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
8126 let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
8127 .filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
8128 .max_by_key(|e| e.version).expect("the owner's metadata edition");
8129 assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
8130
8131 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8134 let bundle_json = serde_json::to_string(&bundle).unwrap();
8135 bed.swap_to(&admin);
8136 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8137 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8138 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8139 set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
8140
8141 let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
8142 .find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
8143 .expect("the admin's banlist edition");
8144 let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
8145 assert_eq!(
8146 cite.entity_id,
8147 crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
8148 "the citation must name the ACTOR'S OWN grant coordinate",
8149 );
8150 assert!(cite.version >= 1, "pinned to a real grant version");
8151 }
8152
8153 #[tokio::test]
8154 async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
8155 let (_tmp, _guard, _owner) = init_test_db();
8161 let relay = MemoryRelay::new();
8162 let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
8163
8164 let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
8165 let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
8166 edit_community_metadata(&relay, &community, &meta).await.unwrap();
8167
8168 let updated = follow_control(&relay, &community, &SessionGuard::capture()).await.unwrap()
8169 .expect("the metadata edition is folded");
8170 assert_eq!(
8171 updated.relays.len(),
8172 crate::community::MAX_COMMUNITY_RELAYS,
8173 "a folded relay list must be truncated, never adopted whole",
8174 );
8175
8176 let again = follow_control(&relay, &updated, &SessionGuard::capture()).await.unwrap();
8180 assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
8181 }
8182
8183 #[tokio::test]
8184 async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
8185 let (bed, owner, me) = TestBed::new();
8191 bed.swap_to(&owner);
8192 let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
8193 let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
8194 let rid = "b2".repeat(32);
8195 publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8196 publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
8197
8198 let bundle = bundle_of(&host, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8199 let bundle_json = serde_json::to_string(&bundle).unwrap();
8200 bed.swap_to(&me);
8201 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8202 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8203 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8204
8205 mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
8207
8208 let before = bed.relay.stored_count();
8209 let new_root = [0xE1; 32];
8210 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
8211 let rotation_events = bed.relay.stored_count() - before;
8212
8213 let after_adopt = bed.relay.stored_count();
8214 follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8215 assert_eq!(
8216 bed.relay.stored_count(),
8217 after_adopt,
8218 "adopting a rotation must publish NOTHING when I minted no links here",
8219 );
8220 assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
8221 }
8222
8223 #[tokio::test]
8224 async fn an_expired_link_stops_keeping_the_community_public() {
8225 let (_tmp, _guard, _owner) = init_test_db();
8231 let relay = MemoryRelay::new();
8232 let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
8233
8234 let past = now_ms() - 60_000;
8236 mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
8237 assert!(
8238 !community_is_public(&relay, &community).await,
8239 "an already-expired link must never read as a live door",
8240 );
8241
8242 mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
8244 assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
8245 }
8246
8247 #[tokio::test]
8248 async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
8249 let (_tmp, _guard, _owner) = init_test_db();
8252 let relay = MemoryRelay::new();
8253 let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
8254 assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
8255
8256 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
8257 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
8258 let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
8259 assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
8260
8261 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
8262 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
8263 assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
8264 let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
8265 assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
8266 }
8267
8268 #[tokio::test]
8269 async fn the_registry_is_cached_locally_so_public_private_is_a_sync_read() {
8270 let (_tmp, _guard, _owner) = init_test_db();
8274 let relay = MemoryRelay::new();
8275 let community = create_community(&relay, "Cached", vec!["wss://r".into()], None).await.unwrap();
8276 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8277 let cached = || crate::db::community::get_community_invite_registry(&cid_hex).unwrap();
8278 let per_creator = || crate::db::community::get_invite_link_sets(&cid_hex).unwrap();
8281 assert!(cached().is_empty(), "a fresh community caches an empty registry");
8282 assert!(per_creator().is_empty(), "…and no per-creator sets");
8283
8284 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
8285 assert!(!cached().is_empty(), "minting caches the registry, so the UI reads Public without folding");
8286 let sets = per_creator();
8287 assert_eq!(sets.len(), 1, "the minting creator gets a set");
8288 assert_eq!(sets[0].locators.len(), 1, "carrying exactly their one live link");
8289
8290 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
8292 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
8293 assert!(cached().is_empty(), "retiring the last link empties the cache back to Private");
8294 assert!(per_creator().is_empty(), "…and clears the per-creator sets");
8295 }
8296
8297 #[tokio::test]
8298 async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
8299 let (_tmp, _guard, owner) = init_test_db();
8306 let relay = MemoryRelay::new();
8307 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
8308 mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
8309 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
8310
8311 let cid = community.id();
8312 let control = control_group_key(&community.community_root, cid, community.root_epoch);
8313 let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
8314
8315 let query = Query {
8316 kinds: vec![stream::KIND_WRAP],
8317 authors: vec![control.pk_hex()],
8318 limit: Some(FOLLOW_PAGE),
8319 ..Default::default()
8320 };
8321 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
8322 let target = wraps
8323 .iter()
8324 .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
8325 .filter(|e| e.entity_id == eid)
8326 .max_by_key(|e| e.version)
8327 .expect("the owner published a registry");
8328
8329 let rogue = Keys::generate();
8332 let mut planted = false;
8333 for n in 0..4_000u64 {
8334 let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
8335 let rumor = control::build_edition_rumor(
8336 rogue.public_key(),
8337 vsk::INVITE_LINKS,
8338 &eid,
8339 target.version,
8340 target.prev_hash.as_ref(),
8341 &content,
8342 9_000,
8343 None,
8344 );
8345 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
8346 let (ed, _) = control::open_control_edition(&w, &control).unwrap();
8347 if ed.inner_id < target.inner_id {
8348 relay.publish(&w, &community.relays).await.unwrap();
8349 planted = true;
8350 break;
8351 }
8352 }
8353 assert!(planted, "the test needs a fork that wins the tiebreak");
8354
8355 assert!(
8356 community_is_public(&relay, &community).await,
8357 "an unauthorised fork must not retire the owner's live link"
8358 );
8359 }
8360
8361 #[tokio::test]
8362 async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
8363 let (_tmp, _guard, owner) = init_test_db();
8365 let relay = MemoryRelay::new();
8366 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
8367 let rogue = Keys::generate();
8368 let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
8370 let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
8371 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8372 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
8373 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
8374 relay.publish(&wrap, &community.relays).await.unwrap();
8375 let _ = owner;
8376 assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
8377 }
8378
8379 #[tokio::test]
8380 async fn full_lifecycle_e2e() {
8381 let (bed, owner, member) = TestBed::new();
8387
8388 bed.swap_to(&owner);
8389 let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
8390 let general = community.channels[0].id;
8391 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
8392
8393 let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
8395 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
8396
8397 let rid = "aa".repeat(32);
8399 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
8400 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
8401
8402 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8404 let bundle_json = serde_json::to_string(&bundle).unwrap();
8405 bed.swap_to(&member);
8406 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8407 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
8408 publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
8410
8411 bed.swap_to(&owner);
8413 let session = SessionGuard::capture();
8414 let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
8415 assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
8416
8417 set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
8419 grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
8420 let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
8421 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
8422 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
8424 assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
8425 assert!(
8427 texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
8428 "pre-refounding history stays readable"
8429 );
8430
8431 bed.swap_to(&member);
8434 let session = SessionGuard::capture();
8435 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8436 assert!(follow.self_removed, "the banned member is cryptographically cut");
8437
8438 bed.swap_to(&owner);
8440 dissolve_community(&bed.relay, &refounded).await.unwrap();
8441 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
8442 }
8443
8444 #[tokio::test]
8450 async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
8451 let (bed, owner, member) = TestBed::new();
8456 let attacker = Keys::generate();
8457 bed.swap_to(&owner);
8458 let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
8459 let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
8460 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
8461 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
8462 let session = SessionGuard::capture();
8464 follow_control(&bed.relay, &community, &session).await.unwrap();
8465 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
8466
8467 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;
8469
8470 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
8472 assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
8473
8474 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
8476 assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
8477 }
8478
8479 #[tokio::test]
8480 async fn memberlist_survives_a_refounding_via_the_snapshot() {
8481 let (bed, owner, member) = TestBed::new();
8484 bed.swap_to(&owner);
8485 let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
8486
8487 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
8489 bed.swap_to(&member);
8490 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
8491 bed.swap_to(&owner);
8492 assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
8493
8494 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
8496 assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
8497
8498 let members = memberlist(&bed.relay, &refounded).await.unwrap();
8500 assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
8501 assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
8502 }
8503
8504 #[tokio::test]
8505 async fn e2e_two_accounts_channels_converse_moderate() {
8506 use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
8507 use nostr_sdk::prelude::ToBech32;
8508 let (bed, a, b) = TestBed::new();
8509 let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
8510 let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
8511 println!("\n===== Concord v2 deep e2e =====");
8512 println!("[acct] A (owner) = {a_npub}");
8513 println!("[acct] B (member) = {b_npub}");
8514
8515 bed.swap_to(&a);
8517 let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
8518 let general = community.channels[0].id;
8519 println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
8520
8521 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
8525 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8526 let priv_ch = community.channel(&priv_id).unwrap();
8527 assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
8528 println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
8529
8530 let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
8532 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8533 let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
8534 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8535 println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
8536 assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
8537
8538 let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
8540 send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
8541 send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
8542 println!("[msg] A posted in #general / #announcements / #mods");
8543
8544 let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
8546 publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
8547 publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
8548 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8549 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
8550 println!("[invite] granted B @admin · minted link {}", link.url);
8551
8552 grant_channel_access(&bed.relay, &community, &priv_id, &b.keys.public_key()).await.unwrap();
8555 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(b.keys.public_key()), Some(a.keys.public_key()), None, None)).unwrap();
8556 bed.swap_to(&b);
8557 let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8558 println!("[join] B joined; sees {} channels", b_view.channels.len());
8559 assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
8560 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");
8561 assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
8562 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");
8563 let session_b = SessionGuard::capture();
8566 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
8567 b_view = fresh;
8568 }
8569 println!("[follow] B folded control (roster persisted: B is @admin)");
8570
8571 send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
8573 send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
8574 println!("[msg] B replied in #general + #mods");
8575 let my_pk = b.keys.public_key();
8578 let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
8579 for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
8580 let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
8581 if let Some(o) = outcome { persist_chat(&gh, &o).await; }
8582 }
8583 assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
8584 println!("[persist] #general history persisted into the shared events store");
8585
8586 send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
8588 bed.swap_to(&a);
8589 let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
8590 send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
8591 let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
8592 send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
8593 println!("[ops] reaction + edit + delete round-tripped");
8594
8595 bed.swap_to(&b);
8597 let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
8598 println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
8599 bed.swap_to(&a);
8600 let session = SessionGuard::capture();
8601 if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
8602 community = updated;
8603 }
8604 assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
8605 println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
8606
8607 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
8613 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8614 send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
8615 println!("[channel] +private #vault {} (B is unentitled — no delivery)", crate::simd::hex::bytes_to_hex_32(&vault.0));
8616 bed.swap_to(&b);
8617 let session_b2 = SessionGuard::capture();
8618 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
8619 b_view = fresh;
8620 }
8621 let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
8622 assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
8623 let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
8624 if let Some(fresh) = rf.updated {
8625 b_view = fresh;
8626 }
8627 let ch = b_view.channel(&vault).expect("still recorded");
8628 assert!(ch.key.is_none(), "an unentitled member is never delivered the key");
8629 assert!(
8630 texts_in(&bed.relay, &b_view, &vault).await.is_empty(),
8631 "and reads nothing from it"
8632 );
8633 assert!(
8634 send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.is_err(),
8635 "an unentitled member cannot post into the channel either"
8636 );
8637 bed.swap_to(&a);
8638 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8639 println!("[private] #vault stayed sealed to the unentitled B (no key, no read, no send)");
8640
8641 let members = memberlist(&bed.relay, &community).await.unwrap();
8643 let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
8644 assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
8645 println!("[members] {} members: A + B present", members.len());
8646
8647 set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
8649 grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
8650 let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
8651 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
8652 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
8653 assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
8654 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");
8655 assert!(
8656 texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
8657 "PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
8658 );
8659 println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
8660 bed.swap_to(&b);
8662 let session_b3 = SessionGuard::capture();
8663 assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
8664 println!("[ban] B's rekey-follow: self_removed = true (severed)");
8665
8666 bed.swap_to(&a);
8668 set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
8669 let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
8670 assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
8671 println!("[unban] B removed from the banlist (re-invitable)");
8672
8673 dissolve_community(&bed.relay, &refounded).await.unwrap();
8675 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
8676 println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
8677 }
8678
8679 #[tokio::test]
8685 #[ignore]
8686 async fn live_e2e_two_accounts() {
8687 use crate::community::transport::LiveTransport;
8688 use nostr_sdk::prelude::ToBech32;
8689
8690 let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
8691 let relays = vec![relay.clone()];
8692 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
8693 crate::db::close_database();
8694 crate::db::clear_id_caches();
8695 let tmp = tempfile::tempdir().unwrap();
8696 crate::db::set_app_data_dir(tmp.path().to_path_buf());
8697
8698 let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
8700 let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
8701
8702 let log = |line: String| {
8703 println!("{line}");
8704 if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
8705 use std::io::Write;
8706 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
8707 let _ = writeln!(f, "{line}");
8708 }
8709 }
8710 };
8711 log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
8712 log(format!("VECTOR_E2E_NSEC_A={} ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
8713 log(format!("VECTOR_E2E_NSEC_B={} ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
8714
8715 for k in [&a, &b] {
8716 let npub = k.public_key().to_bech32().unwrap();
8717 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
8718 crate::db::set_current_account(npub.clone()).unwrap();
8719 crate::db::init_database(&npub).unwrap();
8720 }
8721 let client = crate::nostr_client_builder().build();
8724 client.add_managed_relay(relay.as_str()).await.ok();
8725 client.connect().await;
8726 crate::state::set_nostr_client(client);
8727 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
8728 let become_acct = |k: &Keys| {
8729 let npub = k.public_key().to_bech32().unwrap();
8730 crate::db::set_current_account(npub.clone()).unwrap();
8731 crate::db::init_database(&npub).unwrap();
8732 crate::db::clear_id_caches();
8733 crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
8734 crate::state::set_my_public_key(k.public_key());
8735 };
8736 let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
8737
8738 become_acct(&a);
8740 let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
8741 let general = community.channels[0].id;
8742 log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
8743 send_message(&transport, &community, &general, "A: live hello").await.expect("send");
8744 let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
8745 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8746 log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
8747 grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
8748 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
8749 log(format!("[invite] B granted @admin · link {}", link.url));
8750 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(a.public_key()), None, None)).unwrap();
8751 settle().await;
8752
8753 become_acct(&b);
8755 let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
8756 log(format!("[join] B joined; {} channels", b_view.channels.len()));
8757 settle().await;
8758 let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
8759 let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
8760 log(format!("[read] B sees #general: {seen:?}"));
8761 assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
8762 send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
8763
8764 let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
8768 let hello_id = hello.event.opened().rumor_id.to_hex();
8769 let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
8770 let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
8771 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());
8772 let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
8773 transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
8774 log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
8775 settle().await;
8776
8777 become_acct(&a);
8779 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8780 let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
8781 let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
8782 if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
8783 assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
8784 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");
8785 } else {
8786 panic!("the 1111 parsed as a Message");
8787 }
8788 log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
8789 become_acct(&b);
8790 settle().await;
8791
8792 become_acct(&a);
8796 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8797 let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
8798 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8799 send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
8800 log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
8801 settle().await;
8802
8803 become_acct(&b);
8804 let session_b = SessionGuard::capture();
8805 let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
8806 if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
8807 b_view = fresh;
8808 }
8809 if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
8810 b_view = fresh;
8811 }
8812 let vch = b_view.channel(&vault).expect("B folded the vault");
8813 assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
8814 let vseen = texts_in(&transport, &b_view, &vault).await;
8815 log(format!("[read] B sees #vault: {vseen:?}"));
8816 assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
8817 send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
8818 settle().await;
8819
8820 become_acct(&a);
8821 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8822 assert!(
8823 texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
8824 "A reads B's private reply"
8825 );
8826 log("[private] two-way #vault conversation over the live relay".to_string());
8827
8828 set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
8830 grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
8831 let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
8832 log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
8833 settle().await;
8834 dissolve_community(&transport, &refounded).await.expect("dissolve");
8835 log("[dissolve] community sealed".to_string());
8836 log("===== LIVE e2e PASS =====".to_string());
8837 }
8838
8839 #[tokio::test]
8840 async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
8841 let (bed, owner, member) = TestBed::new();
8845 bed.swap_to(&owner);
8846 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
8847 let general = community.channels[0].id;
8848 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
8849
8850 bed.swap_to(&member);
8851 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
8852 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
8853
8854 bed.swap_to(&owner);
8856 dissolve_community(&bed.relay, &community).await.unwrap();
8857
8858 bed.swap_to(&member);
8860 let session = SessionGuard::capture();
8861 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8862 assert!(follow.dissolved, "the catch-up surfaces the tombstone");
8863 assert!(!follow.self_removed && follow.updated.is_none());
8864 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
8865 assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
8866 let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
8867 assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
8868 let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8870 assert!(again.dissolved && again.updated.is_none());
8871 }
8872
8873 #[tokio::test]
8874 async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
8875 const PRIV_CHANNELS: usize = 6;
8880 let (bed, owner, member) = TestBed::new();
8881 bed.swap_to(&owner);
8882 let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
8883 let mut priv_ids = Vec::new();
8884 for i in 0..PRIV_CHANNELS {
8885 let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
8886 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8887 send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
8888 priv_ids.push(id);
8889 }
8890 for id in &priv_ids {
8892 grant_channel_access(&bed.relay, &community, id, &member.keys.public_key()).await.unwrap();
8893 }
8894 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
8895
8896 bed.swap_to(&member);
8898 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8899 assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
8900
8901 bed.swap_to(&owner);
8903 for epoch in 1..=2u64 {
8904 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
8905 assert_eq!(community.root_epoch, Epoch(epoch));
8906 for id in &priv_ids {
8907 send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
8908 }
8909 }
8910
8911 bed.swap_to(&member);
8913 let session = SessionGuard::capture();
8914 let mut passes = 0;
8915 loop {
8916 passes += 1;
8917 assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
8918 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8919 let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
8920 assert!(!rk.self_removed);
8921 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8922 let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
8923 if rk.updated.is_none() && ctl.is_none() {
8924 break;
8925 }
8926 }
8927 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8928 assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
8929 for id in &priv_ids {
8931 let mine = caught_up.channel(id).expect("channel survived");
8932 let theirs = community.channel(id).unwrap();
8933 assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
8934 assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
8935 let texts = texts_in(&bed.relay, &caught_up, id).await;
8936 let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
8937 assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
8938 for epoch in 1..=2u64 {
8939 assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
8940 }
8941 }
8942 }
8943
8944 #[tokio::test]
8945 async fn an_offline_member_catches_up_across_three_refoundings() {
8946 use nostr_sdk::prelude::ToBech32;
8951 let (bed, owner, member) = TestBed::new();
8952 bed.swap_to(&owner);
8953 let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
8954 let general = community.channels[0].id;
8955 let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
8956 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8957 send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
8958 send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
8959 grant_channel_access(&bed.relay, &community, &mods, &member.keys.public_key()).await.unwrap();
8961 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
8962
8963 bed.swap_to(&member);
8965 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8966 assert_eq!(member_view.root_epoch, Epoch(0));
8967
8968 bed.swap_to(&owner);
8970 let stranger = Keys::generate();
8971 for epoch in 1..=3u64 {
8972 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
8973 assert_eq!(community.root_epoch, Epoch(epoch));
8974 send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
8975 send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
8976 }
8977 let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
8978 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8979 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
8980 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8981 grant_channel_access(&bed.relay, &community, &vault, &member.keys.public_key()).await.unwrap();
8984 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8985 send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
8986 set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
8987 let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
8988 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
8989
8990 bed.swap_to(&member);
8993 let session = SessionGuard::capture();
8994 let mut passes = 0;
8995 loop {
8996 passes += 1;
8997 assert!(passes <= 6, "catch-up must converge, not churn");
8998 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8999 let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
9000 assert!(!rekeyed.self_removed, "the member was never removed");
9001 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9002 let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
9003 if rekeyed.updated.is_none() && controlled.is_none() {
9004 break;
9005 }
9006 }
9007 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9008
9009 assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
9011 assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
9012 assert_eq!(caught_up.name, "Sleeper Reborn");
9013 assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
9015 let m = caught_up.channel(&mods).expect("mods survived");
9016 let owner_mods = community.channel(&mods).unwrap();
9017 assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
9018 assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
9019 let v = caught_up.channel(&vault).expect("vault folded in");
9020 assert!(v.private && v.key.is_none(), "vault folds in keyless: entitled, but never delivered");
9025 let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
9027 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
9028 assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
9029 let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
9032 for epoch in 0..=3u64 {
9033 let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
9034 assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
9035 }
9036 let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
9037 for epoch in 0..=3u64 {
9038 let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
9039 assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
9040 }
9041 assert!(texts_in(&bed.relay, &caught_up, &vault).await.is_empty());
9044 send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
9046 bed.swap_to(&owner);
9047 assert!(
9048 texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
9049 "the caught-up member converses at the new epoch ({})",
9050 member.keys.public_key().to_bech32().unwrap()
9051 );
9052 }
9053
9054 async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
9057 let general = community.channels[0].id;
9058 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
9059 for i in 0..n {
9060 let at = base_secs + i as u64;
9061 let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
9062 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
9063 relay.publish(&wrap, &community.relays).await.unwrap();
9064 }
9065 }
9066
9067 #[tokio::test]
9068 async fn the_history_walk_pages_past_a_multi_page_burst() {
9069 let (_tmp, _guard, owner) = init_test_db();
9072 let relay = MemoryRelay::new();
9073 let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
9074 let general = community.channels[0].id;
9075 flood_general(&relay, &community, &owner, 120, 10_000).await;
9076
9077 let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
9078 assert_eq!(all.len(), 120, "the walk pages the whole burst");
9079 let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
9081 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
9082 assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
9083 let unique: std::collections::HashSet<&String> = contents.iter().collect();
9084 assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
9085
9086 let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
9088 assert_eq!(one.len(), 50, "fetch_channel is one newest page");
9089 assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
9090 }
9091
9092 #[tokio::test]
9093 async fn a_start_until_cursor_pages_history_from_that_point_backwards() {
9094 let (_tmp, _guard, owner) = init_test_db();
9098 let relay = MemoryRelay::new();
9099 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
9100 let general = community.channels[0].id;
9101 flood_general(&relay, &community, &owner, 120, 10_000).await;
9102
9103 let older = fetch_channel_history(
9104 &relay, &community, &general, 50, 8, None, Some(10_059),
9105 crate::community::transport::Evidence::Quorum, |_| true,
9106 )
9107 .await
9108 .unwrap();
9109 let contents: Vec<String> = older.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
9110 assert_eq!(contents.len(), 60, "everything at-or-before the cursor, nothing after");
9111 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
9112 assert_eq!(contents.last().map(String::as_str), Some("msg 59"));
9113 }
9114
9115 #[tokio::test]
9116 async fn the_history_walk_stops_when_the_caller_is_caught_up() {
9117 let (_tmp, _guard, owner) = init_test_db();
9118 let relay = MemoryRelay::new();
9119 let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
9120 let general = community.channels[0].id;
9121 flood_general(&relay, &community, &owner, 120, 10_000).await;
9122
9123 let mut pages = 0usize;
9125 let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| {
9126 pages += 1;
9127 false
9128 })
9129 .await
9130 .unwrap();
9131 assert_eq!(pages, 1, "the early stop is consulted once");
9132 assert_eq!(got.len(), 50, "only the newest page is fetched");
9133 assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
9134 }
9135
9136 #[tokio::test]
9137 async fn a_same_second_history_wall_terminates_instead_of_looping() {
9138 let (_tmp, _guard, owner) = init_test_db();
9142 let relay = MemoryRelay::new();
9143 let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
9144 let general = community.channels[0].id;
9145 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
9146 for i in 0..60usize {
9147 let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
9148 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
9149 relay.publish(&wrap, &community.relays).await.unwrap();
9150 }
9151 let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
9152 assert!(got.len() >= 25, "at least the relay page is read");
9153 assert!(got.len() <= 60, "sane bound");
9154 }
9156
9157 #[tokio::test]
9158 async fn a_grant_revoke_survives_a_withholding_relay() {
9159 let (_tmp, _guard, owner) = init_test_db();
9162 let relay = MemoryRelay::new();
9163 let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
9164 let admin = Keys::generate();
9165 let rid = "d4".repeat(32);
9166 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
9167 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
9168 let session = SessionGuard::capture();
9169 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();
9172
9173 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
9175 let mut stale = community.clone();
9176 stale.relays = vec!["wss://stale".into()];
9177 let floors = load_floors(&community);
9178 let editions = fetch_control(&relay, &stale).await;
9179 let authority = fold_authority(&stale, &editions, &floors);
9180 assert!(
9181 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
9182 "the persisted grant floor refuses the rolled-back (re-granted) view"
9183 );
9184 }
9185
9186 fn load_floors(community: &CommunityV2) -> Floors {
9188 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9189 crate::db::community::get_all_edition_heads_full(&cid_hex)
9190 .unwrap_or_default()
9191 .into_iter()
9192 .filter(|(_, f)| f.0 == community.root_epoch.0)
9193 .map(|(e, f)| (e, (f.1, f.2, f.3)))
9194 .collect()
9195 }
9196
9197 async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
9199 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9200 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9201 relay
9202 .fetch(&q, &community.relays)
9203 .await
9204 .unwrap_or_default()
9205 .iter()
9206 .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
9207 .collect()
9208 }
9209
9210 #[tokio::test]
9211 async fn follow_control_is_a_noop_on_a_freshly_created_community() {
9212 let (_tmp, _guard, _owner) = init_test_db();
9213 let relay = MemoryRelay::new();
9214 let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
9215 let session = SessionGuard::capture();
9216 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
9218 }
9219
9220 #[tokio::test]
9221 async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
9222 let (_tmp, _guard, owner) = init_test_db();
9223 let relay = MemoryRelay::new();
9224 let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
9225 let new_id = ChannelId([0x5a; 32]);
9226 publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
9227
9228 let session = SessionGuard::capture();
9229 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
9230 assert_eq!(updated.channels.len(), 2);
9231 let added = updated.channel(&new_id).expect("the new channel folded in");
9232 assert_eq!(added.name, "announcements");
9233 assert!(!added.private);
9234 assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
9235
9236 let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
9238 let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
9239 assert!(authors.contains(&addr), "the added channel joins the live subscription");
9240
9241 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9243 assert!(reloaded.channel(&new_id).is_some());
9244 }
9245
9246 #[tokio::test]
9247 async fn follow_control_renames_the_community_and_an_existing_channel() {
9248 let (_tmp, _guard, owner) = init_test_db();
9249 let relay = MemoryRelay::new();
9250 let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
9251 let general = community.channels[0].id;
9252 publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
9254 publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
9255
9256 let session = SessionGuard::capture();
9257 let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
9258 assert_eq!(updated.name, "New Name");
9259 assert_eq!(updated.channel(&general).unwrap().name, "lobby");
9260 assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
9261 }
9262
9263 #[tokio::test]
9264 async fn follow_control_deletes_a_channel() {
9265 let (_tmp, _guard, owner) = init_test_db();
9266 let relay = MemoryRelay::new();
9267 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
9268 let extra = ChannelId([0x77; 32]);
9269 let session = SessionGuard::capture();
9270
9271 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
9273 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
9274 assert!(with_extra.channel(&extra).is_some());
9275
9276 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
9278 let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
9279 assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
9280 assert_eq!(updated.channels.len(), 1, "only #general remains");
9281 }
9282
9283 async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
9287 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9288 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9289 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
9290 for w in &wraps {
9291 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
9292 if ed.version <= max_version {
9293 relay.inject(w, &[stale_relay.to_string()]);
9294 }
9295 }
9296 }
9297 }
9298
9299 #[tokio::test]
9300 async fn a_withholding_relay_cannot_roll_back_a_rename() {
9301 let (_tmp, _guard, owner) = init_test_db();
9304 let relay = MemoryRelay::new();
9305 let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
9306 publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
9307
9308 let session = SessionGuard::capture();
9309 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
9310 assert_eq!(updated.name, "Renamed");
9311
9312 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
9314 let mut stale_view = updated.clone();
9315 stale_view.relays = vec!["wss://stale".into()];
9316 assert!(
9317 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
9318 "a stale-only relay must not change the held view"
9319 );
9320 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9321 assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
9322 }
9323
9324 #[tokio::test]
9325 async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
9326 let (_tmp, _guard, owner) = init_test_db();
9327 let relay = MemoryRelay::new();
9328 let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
9329 let extra = ChannelId([0x44; 32]);
9330 let session = SessionGuard::capture();
9331
9332 publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
9335 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
9336
9337 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
9338 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
9339 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
9340 let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
9341 assert!(pruned.channel(&extra).is_none());
9342
9343 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
9345 let mut stale_view = pruned.clone();
9346 stale_view.relays = vec!["wss://stale".into()];
9347 assert!(
9348 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
9349 "the withheld delete must not resurrect the channel"
9350 );
9351 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9352 assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
9353 }
9354
9355 #[tokio::test]
9356 async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
9357 let (_tmp, _guard, owner) = init_test_db();
9361 let relay = MemoryRelay::new();
9362 let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
9363 let session = SessionGuard::capture();
9364 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
9365 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
9366 assert_eq!(updated.name, "Edited");
9367
9368 let mut refounded = updated.clone();
9371 refounded.root_epoch = crate::community::Epoch(1);
9372 crate::db::community::save_community_v2(&refounded).unwrap();
9373 publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
9374
9375 let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
9376 assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
9377 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9379 let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
9380 assert!(
9381 heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
9382 "the adopted head carries the fold's epoch + version"
9383 );
9384 }
9385
9386 #[tokio::test]
9387 async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
9388 let (_tmp, _guard, owner) = init_test_db();
9393 let relay = MemoryRelay::new();
9394 let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
9395 let session = SessionGuard::capture();
9396 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9397 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
9398
9399 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
9400 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
9401 assert_eq!(ours.name, "Ours");
9402
9403 let our_inner = {
9405 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9406 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
9407 wraps
9408 .iter()
9409 .find_map(|w| {
9410 control::open_control_edition(w, &group)
9411 .ok()
9412 .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
9413 .map(|(ed, _)| ed.inner_id)
9414 })
9415 .unwrap()
9416 };
9417
9418 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
9421 let content = serde_json::to_string(&meta).unwrap();
9422 let mut ts = 2_000u64;
9423 let fork_wrap = loop {
9424 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
9425 let inner = rumor.id.unwrap().to_bytes();
9426 if inner < our_inner {
9427 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
9428 }
9429 ts += 1;
9430 };
9431 relay.publish(&fork_wrap, &community.relays).await.unwrap();
9432
9433 let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
9434 assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
9435 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9436 let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
9437 assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
9438 }
9439
9440 #[tokio::test]
9441 async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
9442 let (_tmp, _guard, owner) = init_test_db();
9446 let relay = MemoryRelay::new();
9447 let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
9448 let session = SessionGuard::capture();
9449 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9450
9451 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
9452 let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
9453
9454 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
9456 let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
9457 let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
9458 let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
9459 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
9460 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
9461 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
9462 relay.publish(&w4, &community.relays).await.unwrap();
9463
9464 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
9465 assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
9466
9467 relay.publish(&w3, &community.relays).await.unwrap();
9468 let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
9469 assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
9470 }
9471
9472 #[tokio::test]
9473 async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
9474 let (_tmp, _guard, owner) = init_test_db();
9478 let relay = MemoryRelay::new();
9479 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
9480 let session = SessionGuard::capture();
9481 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9482
9483 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
9484 let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
9485 publish_community_meta(&relay, &base, &owner, "Three", 3).await; let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
9487
9488 let rogue = Keys::generate();
9491 for i in 0..(FOLLOW_PAGE as u64 - 1) {
9492 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
9493 let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
9494 relay.publish(&w, &community.relays).await.unwrap();
9495 }
9496 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
9499 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
9500 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
9501 relay.publish(&w4, &community.relays).await.unwrap();
9502
9503 let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
9504 assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
9505 }
9506
9507 #[tokio::test]
9508 async fn a_follow_after_delete_does_not_resurrect_the_community() {
9509 let (_tmp, _guard, owner) = init_test_db();
9512 let relay = MemoryRelay::new();
9513 let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
9514 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
9515 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9516 crate::db::community::delete_community(&cid_hex).unwrap();
9517
9518 let session = SessionGuard::capture();
9519 assert!(
9520 follow_control(&relay, &community, &session).await.unwrap().is_none(),
9521 "a follow racing a delete is a no-op"
9522 );
9523 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
9524 assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
9525 }
9526
9527 #[tokio::test]
9528 async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
9529 let (_tmp, _guard, owner) = init_test_db();
9532 let relay = MemoryRelay::new();
9533 let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
9534 let new_root = [0xB2; 32];
9535 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
9536 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9537 crate::db::community::delete_community(&cid_hex).unwrap();
9538
9539 let session = SessionGuard::capture();
9540 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
9541 assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
9542 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
9543 }
9544
9545 #[tokio::test]
9546 async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
9547 let (bed, owner, member) = TestBed::new();
9552 bed.swap_to(&owner);
9553 let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
9554 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9555 let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
9556
9557 let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
9559 let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
9560 let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
9561 let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
9562 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
9563 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);
9564 let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
9565 bed.relay.publish(&w3, &community.relays).await.unwrap();
9566
9567 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
9568 let bundle_json = serde_json::to_string(&bundle).unwrap();
9569 bed.swap_to(&member);
9570 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9571 assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
9572 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
9573 let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
9574 assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
9575 }
9576
9577 #[tokio::test]
9578 async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
9579 let (_tmp, _guard, owner) = init_test_db();
9583 let relay = MemoryRelay::new();
9584 let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
9585 let session = SessionGuard::capture();
9586 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9587 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
9588
9589 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
9590 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
9591 assert_eq!(ours.name, "Ours");
9592 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9593 let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
9594 let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
9595
9596 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
9599 let content = serde_json::to_string(&meta).unwrap();
9600 let mut ts = 5_000u64;
9601 let fork_wrap = loop {
9602 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
9603 if rumor.id.unwrap().to_bytes() > our_inner {
9604 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
9605 }
9606 ts += 1;
9607 };
9608 inject_stale_prefix(&relay, &community, 1, "wss://stale").await; relay.inject(&fork_wrap, &["wss://stale".to_string()]);
9610 let mut stale_view = ours.clone();
9611 stale_view.relays = vec!["wss://stale".into()];
9612
9613 assert!(
9614 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
9615 "a losing fork served without our floor edition changes nothing"
9616 );
9617 let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
9618 assert_eq!(held_after, held_before, "the floor row is untouched");
9619 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9620 assert_eq!(held.name, "Ours", "the held state is untouched");
9621 }
9622
9623 #[tokio::test]
9624 async fn follow_control_ignores_a_non_owner_edition() {
9625 let (_tmp, _guard, _owner) = init_test_db();
9629 let relay = MemoryRelay::new();
9630 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
9631 let rogue = Keys::generate();
9632 let rogue_id = ChannelId([0x99; 32]);
9633 publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
9634
9635 let session = SessionGuard::capture();
9636 assert!(
9637 follow_control(&relay, &community, &session).await.unwrap().is_none(),
9638 "a non-owner control edition is not folded"
9639 );
9640 }
9641
9642 #[tokio::test]
9643 async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
9644 let (_tmp, _guard, owner) = init_test_db();
9648 let relay = MemoryRelay::new();
9649 let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
9650 let priv_id = ChannelId([0x33; 32]);
9651 publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
9652
9653 let session = SessionGuard::capture();
9654 let updated = follow_control(&relay, &community, &session)
9655 .await
9656 .unwrap()
9657 .expect("the keyless record is a change");
9658 let ch = updated.channel(&priv_id).expect("the private channel is recorded");
9659 assert!(ch.private && ch.key.is_none(), "recorded keyless");
9660 assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
9661 assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
9662 assert!(
9663 fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
9664 "a keyless fetch returns empty (and never queries the root plane)"
9665 );
9666 assert!(
9667 send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
9668 "a keyless send refuses"
9669 );
9670 let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
9673 let rch = reloaded.channel(&priv_id).unwrap();
9674 assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
9675 let bundle = bundle_of(&reloaded, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
9679 assert!(
9680 !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
9681 "an ungrantable keyless channel stays out of invite bundles"
9682 );
9683 }
9684
9685 #[tokio::test]
9686 async fn a_link_bundle_never_carries_a_private_channel_key() {
9687 let (_tmp, _guard, _owner) = init_test_db();
9692 let relay = MemoryRelay::new();
9693 let community = create_community(&relay, "Leak", vec!["wss://r".into()], None).await.unwrap();
9694 create_private_channel(&relay, &community, "mods").await.unwrap();
9695 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9696 let priv_hex = held
9697 .channels
9698 .iter()
9699 .find(|c| c.private)
9700 .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
9701 .expect("the private channel is held WITH its key");
9702
9703 let link = bundle_of(&held, BundleAudience::Link, None, None, None);
9704 assert!(
9705 !link.channels.iter().any(|c| c.id == priv_hex),
9706 "a held private key must never ride a link bundle"
9707 );
9708 assert!(
9709 link.channels.iter().any(|c| c.id != priv_hex),
9710 "the public channels still ride it"
9711 );
9712
9713 let stranger = bundle_of(&held, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
9717 assert!(
9718 !stranger.channels.iter().any(|c| c.id == priv_hex),
9719 "an unentitled member gets no private key"
9720 );
9721 let mine = bundle_of(&held, BundleAudience::Member(me_pk().unwrap()), None, None, None);
9722 assert!(
9723 mine.channels.iter().any(|c| c.id == priv_hex),
9724 "the creator is entitled via the companion access role"
9725 );
9726 }
9727
9728 #[tokio::test]
9729 async fn a_private_channel_mints_its_access_role_and_entitlement_follows_the_grant() {
9730 let (_tmp, _guard, _owner) = init_test_db();
9733 let relay = MemoryRelay::new();
9734 let community = create_community(&relay, "Scoped", vec!["wss://r".into()], None).await.unwrap();
9735 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
9736 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
9737 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9738
9739 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
9740 let access = roster.channel_roles(&chan_hex);
9741 assert_eq!(access.len(), 1, "the channel minted exactly one access role");
9742 assert!(
9743 access[0].permissions == crate::community::roles::Permissions::empty(),
9744 "the access role confers READ access (key possession), never authority"
9745 );
9746 assert_eq!(access[0].name, "mods", "named for its channel");
9747
9748 let stranger = Keys::generate().public_key();
9750 let owner_hex = community.owner().unwrap().to_hex();
9751 assert!(!roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]));
9752
9753 let role_id = access[0].role_id.clone();
9756 assert!(
9757 roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, std::slice::from_ref(&role_id), &[]),
9758 "the grant overlay entitles before the fold catches up"
9759 );
9760
9761 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9762 grant_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
9763 let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
9764 assert!(
9765 after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
9766 "the grant landed in the local roster (the fold runs later)"
9767 );
9768 let vend = bundle_of(&held, BundleAudience::Member(stranger), None, None, None);
9769 assert!(
9770 vend.channels.iter().any(|c| c.id == chan_hex),
9771 "a now-entitled member's bundle carries the channel key"
9772 );
9773
9774 revoke_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
9775 let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
9776 assert!(
9777 !after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
9778 "the revoke dropped the access role"
9779 );
9780 let rotated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9781 assert_eq!(
9782 rotated.channel(&priv_id).unwrap().epoch,
9783 Epoch(2),
9784 "the revoke rotated the channel — a removal that doesn't rekey severs nobody"
9785 );
9786
9787 let access = crate::VectorCore.channel_access(&cid_hex, &chan_hex).unwrap();
9789 assert_eq!(access["private"], true);
9790 assert_eq!(access["readable"], true, "we minted it, so we hold its key");
9791 assert_eq!(access["roles"].as_array().unwrap().len(), 1, "one access role");
9792 let holders = access["members"].as_array().unwrap();
9793 let me_npub = {
9794 use nostr_sdk::prelude::ToBech32;
9795 me_pk().unwrap().to_bech32().unwrap()
9796 };
9797 assert_eq!(holders.len(), 1, "only the creator holds it — the revoked member is gone");
9798 assert_eq!(holders[0], serde_json::json!(me_npub), "and that holder is the creator");
9799 }
9800
9801 #[tokio::test]
9802 async fn a_vended_key_parks_until_the_fold_proves_the_grant_then_adopts() {
9803 let (bed, owner, member) = TestBed::new();
9807 bed.swap_to(&owner);
9808 let community = create_community(&bed.relay, "Vend", bed.relays.clone(), None).await.unwrap();
9809 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9810 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9811 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
9812 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9813 let real_key = held.channel(&priv_id).unwrap().key.unwrap();
9814 let owner_hex = community.owner().unwrap().to_hex();
9815
9816 bed.swap_to(&member);
9819 let me = member.keys.public_key().to_hex();
9820 let mut member_view = held.clone();
9823 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
9824 c.key = None;
9825 c.epoch = Epoch(0);
9826 }
9827
9828 let empty = crate::community::roles::CommunityRoles::default();
9831 assert!(matches!(
9832 judge_channel_key_vend(&member_view, &empty, &priv_id, Epoch(1), &owner_hex),
9833 VendVerdict::Park(_)
9834 ));
9835
9836 let mut public_view = member_view.clone();
9838 if let Some(c) = public_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
9839 c.private = false;
9840 }
9841 assert!(matches!(
9842 judge_channel_key_vend(&public_view, &empty, &priv_id, Epoch(1), &owner_hex),
9843 VendVerdict::Refuse(_)
9844 ));
9845
9846 assert!(matches!(
9848 judge_channel_key_vend(&member_view, &empty, &ChannelId([0x77; 32]), Epoch(1), &owner_hex),
9849 VendVerdict::Park(_)
9850 ));
9851
9852 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
9855 crate::db::community::set_community_roles(&cid_hex, &empty, 0).unwrap();
9856 crate::db::community::save_community_v2(&member_view).unwrap();
9857 let session = SessionGuard::capture();
9858 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9859 assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "unprovable vend adopts nothing");
9860 assert_eq!(
9861 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
9862 1,
9863 "and stays parked for the next fold"
9864 );
9865
9866 let access = crate::community::roles::Role {
9868 role_id: "44".repeat(32),
9869 name: "mods".into(),
9870 position: u32::MAX - 1,
9871 permissions: crate::community::roles::Permissions::empty(),
9872 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
9873 color: 0,
9874 };
9875 let folded = crate::community::roles::CommunityRoles {
9876 grants: vec![crate::community::roles::MemberGrant { member: me.clone(), role_ids: vec![access.role_id.clone()] }],
9877 roles: vec![access],
9878 };
9879 crate::db::community::set_community_roles(&cid_hex, &folded, 1).unwrap();
9880 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9881 let adopted = absorb_parked_channel_keys(&reloaded, &session);
9882 assert_eq!(adopted.len(), 1, "the re-judge adopts once the grant folds");
9883
9884 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9885 let ch = after.channel(&priv_id).unwrap();
9886 assert_eq!(ch.key, Some(real_key), "adopted the vended key");
9887 assert_eq!(ch.epoch, Epoch(1));
9888 assert!(
9889 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
9890 "and the park is discharged"
9891 );
9892 }
9893
9894 #[tokio::test]
9895 async fn a_vend_at_epoch_zero_is_adopted_onto_a_keyless_channel() {
9896 let (bed, owner, member) = TestBed::new();
9901 bed.swap_to(&owner);
9902 let community = create_community(&bed.relay, "EpochZero", bed.relays.clone(), None).await.unwrap();
9903 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9904 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9905 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
9906 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9907 let vended = [0x5a; 32];
9908 let owner_hex = community.owner().unwrap().to_hex();
9909
9910 bed.swap_to(&member);
9911 let mut member_view = held.clone();
9913 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
9914 c.key = None;
9915 c.epoch = Epoch(0);
9916 }
9917 crate::db::community::save_community_v2(&member_view).unwrap();
9918
9919 let access = crate::community::roles::Role {
9921 role_id: "77".repeat(32),
9922 name: "mods".into(),
9923 position: u32::MAX - 1,
9924 permissions: crate::community::roles::Permissions::empty(),
9925 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
9926 color: 0,
9927 };
9928 let roster = crate::community::roles::CommunityRoles {
9929 grants: vec![crate::community::roles::MemberGrant {
9930 member: member.keys.public_key().to_hex(),
9931 role_ids: vec![access.role_id.clone()],
9932 }],
9933 roles: vec![access],
9934 };
9935 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
9936 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &vended, &owner_hex).unwrap();
9937
9938 let session = SessionGuard::capture();
9939 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9940 let adopted = absorb_parked_channel_keys(&reloaded, &session);
9941 assert_eq!(adopted.len(), 1, "an epoch-0 vend onto a keyless channel is adopted");
9942
9943 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9944 let ch = after.channel(&priv_id).unwrap();
9945 assert_eq!(ch.key, Some(vended), "the key actually landed on the row");
9946 assert_eq!(ch.epoch, Epoch(0), "at the epoch the vendor named");
9947 assert!(
9948 !after.channel_read_coords(ch).is_empty(),
9949 "and the channel is readable — the whole point"
9950 );
9951 assert!(
9952 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
9953 "the park is discharged"
9954 );
9955 }
9956
9957 #[tokio::test]
9958 async fn a_wildly_ahead_vend_epoch_is_refused_not_seated() {
9959 let (bed, owner, member) = TestBed::new();
9965 bed.swap_to(&owner);
9966 let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
9967 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9968 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9969 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
9970 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9971 let owner_hex = community.owner().unwrap().to_hex();
9972
9973 bed.swap_to(&member);
9974 let mut member_view = held.clone();
9975 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
9976 c.key = None;
9977 c.epoch = Epoch(0);
9978 }
9979 crate::db::community::save_community_v2(&member_view).unwrap();
9980 let access = crate::community::roles::Role {
9981 role_id: "99".repeat(32),
9982 name: "mods".into(),
9983 position: u32::MAX - 1,
9984 permissions: crate::community::roles::Permissions::empty(),
9985 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
9986 color: 0,
9987 };
9988 let roster = crate::community::roles::CommunityRoles {
9989 grants: vec![crate::community::roles::MemberGrant {
9990 member: member.keys.public_key().to_hex(),
9991 role_ids: vec![access.role_id.clone()],
9992 }],
9993 roles: vec![access],
9994 };
9995 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
9996 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9997
9998 assert!(matches!(
10000 judge_channel_key_vend(&reloaded, &roster, &priv_id, Epoch(1 << 40), &owner_hex),
10001 VendVerdict::Refuse(_)
10002 ));
10003 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1 << 40, &[0xEE; 32], &owner_hex).unwrap();
10005 let session = SessionGuard::capture();
10006 assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "a poison epoch adopts nothing");
10007 assert!(
10008 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10009 "and the row is discharged rather than parked forever"
10010 );
10011 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10013 assert_eq!(after.channel(&priv_id).unwrap().epoch, Epoch(0), "head never advanced");
10014 assert!(matches!(
10015 judge_channel_key_vend(&after, &roster, &priv_id, Epoch(1), &owner_hex),
10016 VendVerdict::Accept
10017 ));
10018 }
10019
10020 #[tokio::test]
10021 async fn a_channel_rename_lands_locally_without_waiting_for_the_fold() {
10022 let (_tmp, _guard, _owner) = init_test_db();
10025 let relay = MemoryRelay::new();
10026 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10027 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10028 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10029 let key_before = held.channel(&priv_id).unwrap().key;
10030
10031 let mut meta = held.channel(&priv_id).unwrap().metadata();
10032 meta.name = "staff".into();
10033 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10034
10035 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10036 let ch = after.channel(&priv_id).unwrap();
10037 assert_eq!(ch.name, "staff", "the rename is visible immediately");
10038 assert!(ch.private, "and privacy survives the edit");
10039 assert_eq!(ch.key, key_before, "as does the key — a rename is not a rotation");
10040 }
10041
10042 #[tokio::test]
10043 async fn a_channel_rename_carries_its_companion_access_role() {
10044 let (_tmp, _guard, _owner) = init_test_db();
10045 let relay = MemoryRelay::new();
10046 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10047 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10048 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10049 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10050
10051 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10052 let before = roster.channel_roles(&chan_hex);
10053 assert_eq!(before.len(), 1, "one companion role, minted at create");
10054 assert_eq!(before[0].name, "mods", "named after the channel it gates");
10055 let role_id = before[0].role_id.clone();
10056
10057 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10058 let mut meta = held.channel(&priv_id).unwrap().metadata();
10059 meta.name = "staff".into();
10060 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10061
10062 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10063 let after = roster.channel_roles(&chan_hex);
10064 assert_eq!(after.len(), 1, "renamed in place, never duplicated");
10065 assert_eq!(after[0].role_id, role_id, "a rename is a versioned edit of the same id");
10066 assert_eq!(after[0].name, "staff", "the access role followed the channel");
10067 assert_eq!(
10068 after[0].permissions,
10069 crate::community::roles::Permissions::empty(),
10070 "and still confers read access, never authority"
10071 );
10072 }
10073
10074 #[tokio::test]
10075 async fn a_customised_access_role_name_survives_a_channel_rename() {
10076 let (_tmp, _guard, _owner) = init_test_db();
10079 let relay = MemoryRelay::new();
10080 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10081 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10082 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10083 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10084
10085 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10086 let mut role = roster.channel_roles(&chan_hex)[0].clone();
10087 role.name = "Lab Insiders".into();
10088 set_role(&relay, &community, &role).await.unwrap();
10089 merge_local_roster(&cid_hex, Some(&role), None);
10090
10091 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10092 let mut meta = held.channel(&priv_id).unwrap().metadata();
10093 meta.name = "staff".into();
10094 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10095
10096 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10097 assert_eq!(
10098 roster.channel_roles(&chan_hex)[0].name,
10099 "Lab Insiders",
10100 "a deliberate name is left alone"
10101 );
10102 }
10103
10104 #[tokio::test]
10105 async fn a_squatted_park_row_cannot_suppress_the_genuine_vend() {
10106 let (bed, owner, member) = TestBed::new();
10112 bed.swap_to(&owner);
10113 let community = create_community(&bed.relay, "Squat", bed.relays.clone(), None).await.unwrap();
10114 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10115 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10116 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10117 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10118 let real_key = held.channel(&priv_id).unwrap().key.unwrap();
10119 let owner_hex = community.owner().unwrap().to_hex();
10120
10121 bed.swap_to(&member);
10122 let mut member_view = held.clone();
10123 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10124 c.key = None;
10125 c.epoch = Epoch(0);
10126 }
10127 crate::db::community::save_community_v2(&member_view).unwrap();
10128 let access = crate::community::roles::Role {
10129 role_id: "aa".repeat(32),
10130 name: "mods".into(),
10131 position: u32::MAX - 1,
10132 permissions: crate::community::roles::Permissions::empty(),
10133 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
10134 color: 0,
10135 };
10136 let roster = crate::community::roles::CommunityRoles {
10137 grants: vec![crate::community::roles::MemberGrant {
10138 member: member.keys.public_key().to_hex(),
10139 role_ids: vec![access.role_id.clone()],
10140 }],
10141 roles: vec![access],
10142 };
10143 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
10144
10145 let stranger = Keys::generate().public_key().to_hex();
10147 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 9, &[0xBA; 32], &stranger).unwrap();
10148 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
10150 assert_eq!(
10151 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
10152 2,
10153 "the squatter never displaces the genuine vend — both are candidates"
10154 );
10155
10156 let session = SessionGuard::capture();
10157 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10158 let adopted = absorb_parked_channel_keys(&reloaded, &session);
10159 assert_eq!(adopted.len(), 1, "exactly one adoption");
10160
10161 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10162 let ch = after.channel(&priv_id).unwrap();
10163 assert_eq!(ch.key, Some(real_key), "the OWNER's key won, not the squatter's");
10164 assert_eq!(ch.epoch, Epoch(1), "at the genuine epoch");
10165 assert!(
10166 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10167 "and every candidate for the channel is discharged"
10168 );
10169 }
10170
10171 #[tokio::test]
10172 async fn revoking_without_a_folded_access_role_refuses_instead_of_evicting_everyone() {
10173 let (_tmp, _guard, _owner) = init_test_db();
10178 let relay = MemoryRelay::new();
10179 let community = create_community(&relay, "NoRole", vec!["wss://r".into()], None).await.unwrap();
10180 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10181 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10182 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10183 let before = held.channel(&priv_id).unwrap().epoch;
10184
10185 crate::db::community::set_community_roles(&cid_hex, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
10189 let mut blind = held.clone();
10190 blind.relays = vec!["wss://empty".into()];
10191 let err = revoke_channel_access(&relay, &blind, &priv_id, &Keys::generate().public_key())
10192 .await
10193 .unwrap_err();
10194 assert!(err.contains("has not folded"), "refuses with a retryable reason: {err}");
10195
10196 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10197 assert_eq!(after.channel(&priv_id).unwrap().epoch, before, "and rotates nothing");
10198 }
10199
10200 async fn publish_base_rotation(
10207 relay: &MemoryRelay,
10208 community: &CommunityV2,
10209 rotator: &Keys,
10210 recipients: &[PublicKey],
10211 new_root: &[u8; 32],
10212 prev_key: &[u8; 32],
10213 ) {
10214 let new_epoch = Epoch(community.root_epoch.0 + 1);
10215 let prev_epoch = community.root_epoch;
10216 let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
10217 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
10218 let blobs: Vec<_> = recipients
10219 .iter()
10220 .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
10221 .collect();
10222 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();
10223 for e in &events {
10224 relay.publish(e, &community.relays).await.unwrap();
10225 }
10226 }
10227
10228 fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
10230 community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
10231 crate::db::community::save_community_v2(community).unwrap();
10232 }
10233
10234 #[tokio::test]
10235 async fn follow_rekeys_is_a_noop_without_rotations() {
10236 let (_tmp, _guard, _owner) = init_test_db();
10237 let relay = MemoryRelay::new();
10238 let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
10239 let session = SessionGuard::capture();
10240 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10241 assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
10242 }
10243
10244 #[tokio::test]
10245 async fn follow_rekeys_adopts_an_owner_base_rotation() {
10246 let (_tmp, _guard, owner) = init_test_db();
10247 let relay = MemoryRelay::new();
10248 let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
10249 let new_root = [0xB1; 32];
10250 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
10252
10253 let session = SessionGuard::capture();
10254 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
10255 assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
10256 assert_eq!(updated.community_root, new_root, "adopted the fresh root");
10257 let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
10259 let general = updated.channels[0].id;
10260 let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
10261 assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
10262 }
10263
10264 #[tokio::test]
10265 async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
10266 let (_tmp, _guard, owner) = init_test_db();
10267 let relay = MemoryRelay::new();
10268 let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
10269 let priv_id = ChannelId([0x33; 32]);
10270 add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
10271
10272 let new_key = [0x55; 32];
10274 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
10275 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
10276 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();
10277 let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None).unwrap();
10278 for e in &events {
10279 relay.publish(e, &community.relays).await.unwrap();
10280 }
10281
10282 let session = SessionGuard::capture();
10283 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
10284 let ch = updated.channel(&priv_id).unwrap();
10285 assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
10286 assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
10287 assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
10288 }
10289
10290 #[tokio::test]
10291 async fn follow_rekeys_ignores_a_non_owner_rotation() {
10292 let (_tmp, _guard, _owner) = init_test_db();
10295 let relay = MemoryRelay::new();
10296 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
10297 let rogue = Keys::generate();
10298 publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
10299
10300 let session = SessionGuard::capture();
10301 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10302 assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
10303 }
10304
10305 #[tokio::test]
10306 async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
10307 let (_tmp, _guard, owner) = init_test_db();
10310 let relay = MemoryRelay::new();
10311 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
10312 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
10314
10315 let session = SessionGuard::capture();
10316 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10317 assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
10318 }
10319
10320 #[tokio::test]
10321 async fn follow_rekeys_holds_on_an_incomplete_rotation() {
10322 let (_tmp, _guard, owner) = init_test_db();
10325 let relay = MemoryRelay::new();
10326 let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
10327 let new_epoch = Epoch(1);
10328 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
10329 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
10330 let other = Keys::generate();
10332 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
10333 let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None).unwrap();
10334 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
10335 relay.publish(&wrap, &community.relays).await.unwrap();
10336
10337 let session = SessionGuard::capture();
10338 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10339 assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
10340 }
10341
10342 #[tokio::test]
10343 async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
10344 let (bed, owner, member) = TestBed::new();
10348 bed.swap_to(&owner);
10349 let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
10350 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
10351
10352 bed.swap_to(&member);
10353 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10354 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
10355
10356 bed.swap_to(&owner);
10358 let stranger = Keys::generate();
10359 publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
10360
10361 bed.swap_to(&member);
10363 let session = SessionGuard::capture();
10364 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
10365 assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
10366 assert!(follow.updated.is_none(), "a removed member adopts nothing");
10367 }
10368
10369 #[tokio::test]
10370 async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
10371 let (_tmp, _guard, owner) = init_test_db();
10377 let relay = MemoryRelay::new();
10378 let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
10379 let root0 = community.community_root;
10380 let priv_id = ChannelId([0x33; 32]);
10381 let key1 = [0x44; 32];
10382 add_private_channel(&mut community, priv_id, key1, Epoch(1));
10383
10384 let key2 = [0x55; 32];
10387 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10388 let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
10389 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();
10390 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() {
10391 relay.publish(&e, &community.relays).await.unwrap();
10392 }
10393
10394 community.community_root = [0xB7; 32];
10398 community.root_epoch = Epoch(1);
10399 crate::db::community::save_community_v2(&community).unwrap();
10400
10401 let session = SessionGuard::capture();
10402 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
10403 let ch = updated.channel(&priv_id).unwrap();
10404 assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
10405 assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
10406 }
10407
10408 #[tokio::test]
10409 async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
10410 let (_tmp, _guard, owner) = init_test_db();
10415 let relay = MemoryRelay::new();
10416 let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
10417 let priv_id = ChannelId([0x66; 32]);
10418 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() });
10419 crate::db::community::save_community_v2(&community).unwrap();
10420 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10421 assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
10422
10423 let stranger = Keys::generate();
10425 let key1 = [0x71; 32];
10426 let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
10427 let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
10428 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();
10429 for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None).unwrap() {
10430 relay.publish(&e, &community.relays).await.unwrap();
10431 }
10432 let key2 = [0x72; 32];
10435 let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10436 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
10437 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();
10438 for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None).unwrap() {
10439 relay.publish(&e, &community.relays).await.unwrap();
10440 }
10441
10442 let session = SessionGuard::capture();
10445 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
10446 let ch = updated.channel(&priv_id).unwrap();
10447 assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
10448 assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
10449 }
10450
10451 #[tokio::test]
10452 async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
10453 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10457 let (_tmp, _guard, _owner) = init_test_db();
10458 let relay = MemoryRelay::new();
10459 let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
10460 let priv_id = ChannelId([0x88; 32]);
10461 let key1 = [0x91; 32];
10462 add_private_channel(&mut community, priv_id, key1, Epoch(1));
10463
10464 let admin = Keys::generate();
10466 let role = Role::admin("aa".repeat(32));
10467 let roster = CommunityRoles {
10468 roles: vec![role.clone()],
10469 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
10470 };
10471 seed_roster_with_heads(&community, &roster, 1_000);
10472
10473 let key2 = [0x92; 32];
10475 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10476 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
10477 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
10478 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
10479 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() {
10480 relay.publish(&e, &community.relays).await.unwrap();
10481 }
10482 let session = SessionGuard::capture();
10483 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
10484 assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
10485
10486 let rogue = Keys::generate();
10488 let key3 = [0x93; 32];
10489 let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
10490 let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
10491 let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
10492 for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None).unwrap() {
10493 relay.publish(&e, &updated.relays).await.unwrap();
10494 }
10495 let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
10496 assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
10497 }
10498
10499 #[tokio::test]
10500 async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
10501 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10506 let (bed, owner, member) = TestBed::new();
10507 bed.swap_to(&owner);
10508 let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
10509
10510 bed.swap_to(&member);
10513 let mut held = community.clone();
10514 let priv_id = ChannelId([0xAB; 32]);
10515 let key1 = [0xA1; 32];
10516 add_private_channel(&mut held, priv_id, key1, Epoch(1));
10517 let peer = Keys::generate();
10518 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10519 let role = Role::admin("bb".repeat(32));
10520 let roster = CommunityRoles {
10521 roles: vec![role.clone()],
10522 grants: vec![
10523 MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
10524 MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
10525 ],
10526 };
10527 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
10528
10529 let key2 = [0xA2; 32];
10531 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10532 let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
10533 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();
10534 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() {
10535 bed.relay.publish(&e, &held.relays).await.unwrap();
10536 }
10537 let session = SessionGuard::capture();
10538 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
10539 assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
10540 let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
10541 assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
10542
10543 let key3 = [0xA3; 32];
10545 let stranger = Keys::generate();
10546 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();
10547 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() {
10548 bed.relay.publish(&e, &held.relays).await.unwrap();
10549 }
10550 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
10551 let updated = follow.updated.expect("the owner's removal folds");
10552 assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
10553 }
10554
10555 #[tokio::test]
10556 async fn converting_a_public_channel_to_private_is_refused() {
10557 let (_tmp, _guard, _owner) = init_test_db();
10560 let relay = MemoryRelay::new();
10561 let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
10562 let general = community.channels[0].id;
10563 let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
10564 let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
10565 assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
10566 let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
10568 edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
10569 }
10570
10571 async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
10574 let doc = super::super::list::CommunityList {
10575 entries: vec![],
10576 tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
10577 extra: Default::default(),
10578 };
10579 let event = super::super::list::build_list_event(me, &doc).unwrap();
10580 relay.publish(&event, relays).await.unwrap();
10581 }
10582
10583 #[tokio::test]
10584 async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
10585 let (_tmp, _guard, me) = init_test_db();
10589 let relay = MemoryRelay::new();
10590 let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
10591 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
10592
10593 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
10595
10596 let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
10598 republish_community_list(&relay, Some(y.id())).await.unwrap();
10599
10600 let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
10602 assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
10603 assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
10604 }
10605
10606 #[tokio::test]
10607 async fn sync_tears_down_a_community_a_sibling_left() {
10608 let (_tmp, _guard, me) = init_test_db();
10611 let relay = MemoryRelay::new();
10612 let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
10613 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
10614 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
10615
10616 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
10617 sync_community_list(&relay, &x.relays).await.unwrap();
10618 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
10619 }
10620
10621 #[tokio::test]
10622 async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
10623 let (_tmp, _guard, me) = init_test_db();
10626 let relay = MemoryRelay::new();
10627 let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
10628 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
10629 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
10631 republish_community_list(&relay, Some(x.id())).await.unwrap();
10633 sync_community_list(&relay, &x.relays).await.unwrap();
10634 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
10635 }
10636
10637 #[tokio::test]
10638 async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
10639 let (_tmp, _guard, _me) = init_test_db();
10642 let good = MemoryRelay::new();
10643 let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
10644 assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
10645
10646 struct FetchErrors;
10648 #[async_trait::async_trait]
10649 impl Transport for FetchErrors {
10650 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
10651 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
10652 panic!("republish must NOT publish when the remote fetch failed");
10653 }
10654 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
10655 Ok(())
10656 }
10657 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
10658 Err("relay unreachable".to_string())
10659 }
10660 }
10661 republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
10663 }
10664
10665 #[tokio::test]
10666 async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
10667 let (_tmp, _guard, owner) = init_test_db();
10673 let relay = MemoryRelay::new();
10674 let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
10675
10676 let lurker = Keys::generate();
10678 let rid = "b1".repeat(32);
10679 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
10680 publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
10681
10682 let members = memberlist(&relay, &community).await.unwrap();
10684 assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
10685
10686 let banned_grantee = Keys::generate();
10688 publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
10689 set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
10690 let members = memberlist(&relay, &community).await.unwrap();
10691 assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
10692 assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
10693
10694 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
10696 assert_eq!(refounded.root_epoch, Epoch(1));
10697 let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
10698 let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
10699 let rotations = rekey::collect_rotations(&chunks);
10700 let lurker_x = lurker.public_key().to_bytes();
10701 let delivered = rotations.iter().any(|r| {
10702 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
10703 });
10704 assert!(delivered, "the Refounding delivered the new root to the granted lurker");
10705 }
10706
10707 #[tokio::test]
10708 async fn the_memberlist_pages_past_a_guestbook_flood() {
10709 let (_tmp, _guard, _owner) = init_test_db();
10713 let relay = MemoryRelay::new();
10714 let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
10715 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
10716
10717 let honest = Keys::generate();
10719 let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
10720 let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
10721 relay.publish(&w, &community.relays).await.unwrap();
10722 for i in 0..600u64 {
10723 let throwaway = Keys::generate();
10724 let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
10725 let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
10726 relay.publish(&w, &community.relays).await.unwrap();
10727 }
10728
10729 let members = memberlist(&relay, &community).await.unwrap();
10730 assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
10731 }
10732
10733 #[tokio::test]
10734 async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
10735 let (_tmp, _guard, owner) = init_test_db();
10740 let relay = MemoryRelay::new();
10741 let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
10742 let new_root = [0xD9; 32];
10743 let new_epoch = Epoch(1);
10744 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
10745
10746 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
10748
10749 let rogue = Keys::generate();
10752 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
10753 for i in 0..260u64 {
10754 let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
10755 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();
10756 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
10757 relay.publish(&wrap, &community.relays).await.unwrap();
10758 }
10759
10760 let session = SessionGuard::capture();
10761 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
10762 assert_eq!(updated.root_epoch, Epoch(1));
10763 assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
10764 }
10765
10766 #[tokio::test]
10767 async fn a_swap_during_create_private_channel_aborts_without_a_write() {
10768 let (bed, owner, _member) = TestBed::new();
10773 bed.swap_to(&owner);
10774 let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
10775 let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
10776
10777 let swap_relay = SwapMidPublish { inner: MemoryRelay::new() };
10779 let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
10780 assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
10781 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10782 assert_eq!(after.channels.len(), before, "no channel row was written");
10783 assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
10784 }
10785
10786 #[tokio::test]
10787 async fn an_uncited_admin_rotation_is_not_adopted() {
10788 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10793 let (_tmp, _guard, _owner) = init_test_db();
10794 let relay = MemoryRelay::new();
10795 let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
10796 let priv_id = ChannelId([0x8A; 32]);
10797 let key1 = [0x93; 32];
10798 add_private_channel(&mut community, priv_id, key1, Epoch(1));
10799
10800 let admin = Keys::generate();
10801 let role = Role::admin("cf".repeat(32));
10802 let roster = CommunityRoles {
10803 roles: vec![role.clone()],
10804 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
10805 };
10806 seed_roster_with_heads(&community, &roster, 1_000);
10807
10808 let key2 = [0x94; 32];
10809 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10810 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
10811 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
10812 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
10813 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None).unwrap() {
10815 relay.publish(&e, &community.relays).await.unwrap();
10816 }
10817
10818 let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
10819 assert!(out.updated.is_none(), "an uncited rotation is not adopted");
10820
10821 let cited = my_authority_citation(&community, &admin.public_key());
10824 assert!(cited.is_some(), "the seeded head yields a citation");
10825 let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
10826 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() {
10827 relay.publish(&e, &community.relays).await.unwrap();
10828 }
10829 let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
10830 assert!(out.updated.is_some(), "the cited rotation IS adopted");
10831 }
10832
10833 #[tokio::test]
10834 async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
10835 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10843 let (_tmp, _guard, _owner) = init_test_db();
10844 let relay = MemoryRelay::new();
10845 let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
10846 let priv_id = ChannelId([0xC0; 32]);
10847 let key1 = [0xC1; 32];
10848 add_private_channel(&mut community, priv_id, key1, Epoch(1));
10849
10850 let (a, b) = (Keys::generate(), Keys::generate());
10852 let role = Role::admin("ce".repeat(32));
10853 let roster = CommunityRoles {
10854 roles: vec![role.clone()],
10855 grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
10856 };
10857 seed_roster_with_heads(&community, &roster, 1_000);
10858
10859 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
10862 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10863 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
10864 let key_a = [0x0A; 32];
10865 let key_b = [0xFB; 32]; for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
10867 let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
10868 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() {
10869 relay.publish(&e, &community.relays).await.unwrap();
10870 }
10871 }
10872
10873 let session = SessionGuard::capture();
10874 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
10875 let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
10876 assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
10877
10878 let mut peer = community.clone();
10880 if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10881 c.key = Some(key1);
10882 c.epoch = Epoch(1);
10883 }
10884 let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
10886 assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
10887 }
10888
10889 #[tokio::test]
10890 async fn create_private_channel_refuses_a_member_without_manage_channels() {
10891 let (bed, owner, member) = TestBed::new();
10894 bed.swap_to(&owner);
10895 let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
10896 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
10897
10898 bed.swap_to(&member);
10899 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10900 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
10901 let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
10902 assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
10903 let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
10904 assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
10905 }
10906
10907 #[tokio::test]
10910 async fn accept_rejects_a_bundle_with_a_forged_community_root() {
10911 let (bed, owner, member) = TestBed::new();
10915 bed.swap_to(&owner);
10916 let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
10917
10918 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
10919 let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
10920 forged.community_root = fake.clone();
10921 for ch in &mut forged.channels {
10922 ch.key = fake.clone();
10923 }
10924 let attacker = Keys::generate();
10925 let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
10926 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
10927
10928 bed.swap_to(&member);
10929 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10930 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
10931 assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
10932 assert!(
10933 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
10934 "a rejected join persists nothing"
10935 );
10936 }
10937
10938 #[tokio::test]
10939 async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
10940 let (bed, owner, member) = TestBed::new();
10945 bed.swap_to(&owner);
10946 let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
10947 let general = community.channels[0].id;
10948
10949 let mut rotated = community.clone();
10950 rotated.community_root = [0x5A; 32];
10951 rotated.root_epoch = Epoch(1);
10952 let admin = Keys::generate();
10953 publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
10954 publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
10955
10956 bed.swap_to(&member);
10957 let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
10958 let session = SessionGuard::capture();
10959 let joined = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
10960 assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
10961 }
10962
10963 #[tokio::test]
10964 async fn only_an_actual_join_publishes_a_guestbook_join() {
10965 let (bed, owner, member) = TestBed::new();
10969 bed.swap_to(&owner);
10970 let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
10971
10972 let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
10973 async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
10974 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
10975 relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
10976 }
10977 let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await; bed.swap_to(&member);
10980 let bundle = bundle_of(&community, BundleAudience::Link, None, None, None);
10981 let session = SessionGuard::capture();
10982 accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
10983 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
10984
10985 accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
10986 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
10987
10988 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10989 crate::db::community::delete_community(&cid_hex).unwrap();
10990 accept_bundle(&bed.relay, &session, &bundle, None, false).await.unwrap();
10991 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
10992 }
10993
10994 #[tokio::test]
10995 async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
10996 let (bed, owner, member) = TestBed::new();
10999 bed.swap_to(&owner);
11000 let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
11001
11002 let mut rotated = community.clone();
11003 rotated.community_root = [0x5B; 32];
11004 rotated.root_epoch = Epoch(1);
11005 let attacker = Keys::generate();
11006 publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
11007
11008 bed.swap_to(&member);
11009 let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
11010 let session = SessionGuard::capture();
11011 let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
11012 assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
11013 }
11014
11015 #[tokio::test]
11016 async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
11017 let (bed, owner, member) = TestBed::new();
11020 bed.swap_to(&owner);
11021 let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
11022 let general = community.channels[0].id;
11023
11024 let mut fake = community.clone();
11025 fake.community_root = [0x5C; 32]; let admin = Keys::generate();
11027 publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
11028 publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
11029
11030 bed.swap_to(&member);
11031 let bundle = bundle_of(&fake, BundleAudience::Link, None, None, None);
11032 let session = SessionGuard::capture();
11033 let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
11034 assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
11035 }
11036
11037 #[tokio::test]
11038 async fn follow_control_heals_a_bundle_misclassified_public_channel() {
11039 let (_tmp, _guard, _owner) = init_test_db();
11043 let relay = MemoryRelay::new();
11044 let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
11045 let general = community.channels[0].id;
11046 let mut poisoned = community.clone();
11047 poisoned.channels[0].private = true;
11048 poisoned.channels[0].key = Some([0x66; 32]);
11049 crate::db::community::save_community_v2(&poisoned).unwrap();
11050
11051 let session = SessionGuard::capture();
11052 let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
11053 let ch = healed.channel(&general).unwrap();
11054 assert!(!ch.private, "the owner's public declaration overrides the bundle");
11055 assert_eq!(ch.key, None, "a healed public channel derives from the root");
11056 }
11057
11058 #[tokio::test]
11059 async fn a_deleted_channel_does_not_resurrect_on_reload() {
11060 let (_tmp, _guard, owner) = init_test_db();
11063 let relay = MemoryRelay::new();
11064 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
11065 let extra = ChannelId([0x77; 32]);
11066 let session = SessionGuard::capture();
11067 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
11068 let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
11069 assert!(with_extra.channel(&extra).is_some());
11070 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
11071 let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
11072 assert!(after.channel(&extra).is_none());
11073
11074 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11075 assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
11076 assert_eq!(reloaded.channels.len(), 1);
11077 }
11078
11079 #[tokio::test]
11080 async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
11081 let (_tmp, _guard, _owner) = init_test_db();
11085 let relay = MemoryRelay::new();
11086 let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
11087 let a_channel = a.channels[0].id;
11088 let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
11089 let b_channel = b.channels[0].id;
11090 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() });
11092
11093 crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
11094 let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
11096 assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
11097 assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
11098 let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
11100 assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
11101 assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
11102 }
11103
11104 struct CappedRelay {
11110 events: Vec<Event>,
11111 cap: usize,
11112 }
11113 #[async_trait::async_trait]
11114 impl Transport for CappedRelay {
11115 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
11116 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
11117 Ok(())
11118 }
11119 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
11120 Ok(())
11121 }
11122 async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
11123 let mut m: Vec<Event> = self
11124 .events
11125 .iter()
11126 .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
11127 .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
11128 .cloned()
11129 .collect();
11130 m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
11132 Ok(m)
11133 }
11134 }
11135
11136 #[tokio::test]
11137 async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
11138 let (_tmp, _guard, _owner) = init_test_db();
11145 let memory = MemoryRelay::new();
11146 let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
11147 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
11148
11149 let rogue = Keys::generate();
11150 let mut events: Vec<Event> = Vec::new();
11151 for i in 0..FOLLOW_PAGE {
11152 let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
11153 let rumor = control::build_edition_rumor(
11154 rogue.public_key(),
11155 vsk::CHANNEL_METADATA,
11156 &[0xAB; 32],
11157 1,
11158 None,
11159 &content,
11160 9_000,
11161 None,
11162 );
11163 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
11164 events.push(w);
11165 }
11166 let relay = CappedRelay { events, cap: FOLLOW_PAGE };
11167
11168 let err = refound_community(&relay, &community, &[])
11169 .await
11170 .expect_err("a plane that can't be read whole must never be compacted");
11171 assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
11172 }
11173
11174 #[tokio::test]
11175 async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
11176 let (_tmp, _guard, owner) = init_test_db();
11181 let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
11182 let g = control::genesis(&owner, meta, 1_000).unwrap();
11183 let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
11184
11185 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
11186 let rogue = Keys::generate();
11187 let mut events: Vec<Event> = g.wraps.to_vec();
11188 for i in 0..250u64 {
11189 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
11190 let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
11191 events.push(wrap);
11192 }
11193 let relay = CappedRelay { events, cap: 100 };
11195 let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
11196 assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
11197 }
11198
11199 #[tokio::test]
11200 async fn accept_parked_invite_joins_from_the_stored_bundle() {
11201 let (bed, owner, member) = TestBed::new();
11204 bed.swap_to(&owner);
11205 let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
11206 let general = community.channels[0].id;
11207 send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
11208 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
11209 let bundle_json = serde_json::to_string(&bundle).unwrap();
11210 let inviter_hex = owner.keys.public_key().to_hex();
11211
11212 bed.swap_to(&member);
11213 let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
11214 assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
11215 assert!(joined.identity.verify());
11216 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
11217 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
11220 assert!(
11221 crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
11222 "the joiner's control floor is seeded from the join-time fold"
11223 );
11224
11225 bed.swap_to(&owner);
11227 let members = memberlist(&bed.relay, &community).await.unwrap();
11228 assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
11229 }
11230
11231 #[tokio::test]
11232 async fn accept_parked_invite_rejects_a_forged_root() {
11233 let (_tmp, _guard, _owner) = init_test_db();
11237 let relay = MemoryRelay::new();
11238 let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
11239 let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
11240 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
11241 forged.community_root = fake.clone();
11242 for ch in &mut forged.channels {
11243 ch.key = fake.clone();
11244 }
11245 let bundle_json = serde_json::to_string(&forged).unwrap();
11246
11247 let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
11248 assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
11249 }
11250
11251 #[test]
11252 fn v2_and_v1_bundles_are_distinguishable_by_parse() {
11253 let owner = Keys::generate();
11257 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
11258 let hex = crate::simd::hex::bytes_to_hex_32;
11259 let v2 = invite::CommunityInvite {
11260 community_id: hex(&identity.community_id.0),
11261 owner: hex(&identity.owner_xonly),
11262 owner_salt: hex(&identity.owner_salt),
11263 community_root: hex(&[0x11; 32]),
11264 root_epoch: 0,
11265 channels: vec![],
11266 relays: vec!["wss://r".into()],
11267 name: "V2".into(),
11268 icon: None,
11269 expires_at: None,
11270 creator_npub: None,
11271 label: None,
11272 extra: Default::default(),
11273 };
11274 let v2_json = serde_json::to_string(&v2).unwrap();
11275 assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
11276 let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
11277 assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
11278 }
11279
11280 #[tokio::test]
11281 async fn verify_rejects_a_cross_community_owner_edition_replay() {
11282 let (_tmp, _guard, owner) = init_test_db();
11288
11289 let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
11291 let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
11292 let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
11293
11294 let t_identity = control::CommunityIdentity::mint(&owner.public_key());
11296 let fake_root = [0xEE; 32];
11297 let t = CommunityV2 {
11298 identity: t_identity,
11299 community_root: fake_root,
11300 root_epoch: Epoch(0),
11301 name: "T".into(),
11302 description: None,
11303 icon: None,
11304 banner: None,
11305 meta_custom: None,
11306 meta_extra: Default::default(),
11307 relays: vec!["wss://r".into()],
11308 channels: vec![],
11309 dissolved: false,
11310 created_at_ms: 0,
11311 };
11312 let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
11315 let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
11316 let relay = MemoryRelay::new();
11317 relay.publish(&replayed, &t.relays).await.unwrap();
11318
11319 let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
11320 assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
11321 }
11322
11323 #[tokio::test]
11331 #[ignore = "hits a real relay over the network"]
11332 async fn live_smoke_create_send_fetch_on_a_real_relay() {
11333 use crate::community::transport::LiveTransport;
11334 use nostr_sdk::prelude::ToBech32;
11335
11336 let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
11337 let relays = vec![relay.clone()];
11338
11339 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
11341 crate::db::close_database();
11342 crate::db::clear_id_caches();
11343 let tmp = tempfile::tempdir().unwrap();
11344 let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
11347 Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
11348 Err(_) => Keys::generate(),
11349 };
11350 let npub = keys.public_key().to_bech32().unwrap();
11351 if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
11354 println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
11355 }
11356 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
11357 crate::db::set_app_data_dir(tmp.path().to_path_buf());
11358 crate::db::set_current_account(npub.clone()).unwrap();
11359 crate::db::init_database(&npub).unwrap();
11360 crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
11361 crate::state::set_my_public_key(keys.public_key());
11362 println!("[smoke] throwaway identity {npub}");
11363
11364 let client = crate::nostr_client_builder().build();
11366 client.add_managed_relay(relay.as_str()).await.ok();
11367 client.connect().await;
11368 crate::state::set_nostr_client(client);
11369 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
11370
11371 let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
11373 let general = community.channels[0].id;
11374 println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
11375
11376 let text = "hello from a Vector Concord v2 live smoke test";
11377 let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
11378 println!("[smoke] sent message {sent_id}");
11379
11380 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
11382
11383 let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
11384 let texts: Vec<String> = page
11385 .iter()
11386 .filter_map(|f| match &f.event {
11387 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
11388 _ => None,
11389 })
11390 .collect();
11391 println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
11392 assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
11393
11394 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
11396 println!("[smoke] invite link: {}", link.url);
11397 println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
11398 }
11399
11400 #[tokio::test]
11401 async fn chat_ops_react_edit_delete_round_trip() {
11402 let (bed, owner, _member) = TestBed::new();
11403 bed.swap_to(&owner);
11404 let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
11405 let general = community.channels[0].id;
11406 let me_hex = owner.keys.public_key().to_hex();
11407
11408 let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
11409 send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
11410 .await
11411 .unwrap();
11412 send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
11413 send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
11414
11415 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11416 let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
11417 let mut saw = (false, false, false);
11418 for f in &page {
11419 match &f.event {
11420 ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
11421 assert_eq!(emoji, ":fire:");
11422 assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
11423 saw.0 = true;
11424 }
11425 ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
11426 assert_eq!(new_content, "edited");
11427 saw.1 = true;
11428 }
11429 ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
11430 _ => {}
11431 }
11432 }
11433 assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
11434 }
11435
11436 #[tokio::test]
11437 async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
11438 let (bed, owner, _member) = TestBed::new();
11439 bed.swap_to(&owner);
11440 let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
11441 let general = community.channels[0].id;
11442 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
11443
11444 let mut sub = bed.relay.subscribe(Query {
11446 kinds: vec![stream::KIND_WRAP_EPHEMERAL],
11447 authors: vec![group.pk_hex()],
11448 ..Default::default()
11449 });
11450 send_typing(&bed.relay, &community, &general).await.unwrap();
11451 let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
11452 let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
11453 Ok(ChatEvent::Typing { opened }) => opened,
11454 other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
11455 };
11456
11457 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11460 assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
11461
11462 assert!(
11464 crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
11465 "ephemeral sends must not retain scrub keys"
11466 );
11467 }
11468
11469 #[tokio::test]
11470 async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
11471 let (bed, owner, _member) = TestBed::new();
11472 bed.swap_to(&owner);
11473 let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
11474 let general = community.channels[0].id;
11475 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
11476
11477 let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
11478
11479 let (keys, outer_hex, relays) =
11482 crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
11483 assert_eq!(relays, community.relays);
11484 let wrap_query = Query {
11485 kinds: vec![stream::KIND_WRAP],
11486 authors: vec![group.pk_hex()],
11487 ..Default::default()
11488 };
11489 let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
11490 let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
11491 assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
11492
11493 let me_hex = owner.keys.public_key().to_hex();
11495 let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
11496 .await
11497 .unwrap();
11498 assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
11499
11500 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
11503 assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
11504 let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
11505 assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
11506 }
11507
11508 #[tokio::test]
11509 async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
11510 let (bed, owner, _member) = TestBed::new();
11511 bed.swap_to(&owner);
11512 let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
11513 let general = community.channels[0].id;
11514 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
11515
11516 let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
11519 crate::db::community::delete_message_key(&id).unwrap();
11520 assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
11521
11522 let mkeys = Keys::generate();
11524 let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
11525 let foreign_id = rumor.id.unwrap().to_hex();
11526 let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
11527 bed.relay.publish(&fw, &community.relays).await.unwrap();
11528
11529 fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11531 let (keys, _outer, relays) =
11532 crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
11533 assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
11534 assert_eq!(relays, community.relays);
11535
11536 assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
11538
11539 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
11541 let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11542 assert!(
11543 !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
11544 "healed message scrubbed from the relay"
11545 );
11546 }
11547
11548 #[tokio::test]
11549 async fn send_chat_message_threads_the_reply_and_extra_tags() {
11550 let (bed, owner, _member) = TestBed::new();
11551 bed.swap_to(&owner);
11552 let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
11553 let general = community.channels[0].id;
11554 let me_hex = owner.keys.public_key().to_hex();
11555
11556 let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
11557 let imeta = nostr_sdk::prelude::Tag::custom(
11558 "imeta",
11559 ["url https://e/blob".to_string(), "m image/png".to_string()],
11560 );
11561 let child_id = send_chat_message(
11562 &bed.relay, &community, &general, "child",
11563 Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
11564 )
11565 .await
11566 .unwrap();
11567
11568 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11569 let child = page
11570 .iter()
11571 .find_map(|f| match &f.event {
11572 ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
11573 _ => None,
11574 })
11575 .expect("the reply message round-trips");
11576 let reply = child.1.as_ref().expect("the reply reference is carried");
11577 assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
11578 assert_eq!(reply.author, Some(owner.keys.public_key()));
11579 assert!(
11580 child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
11581 "the imeta attachment tag rides the rumor verbatim"
11582 );
11583 }
11584
11585 #[tokio::test]
11586 async fn a_kick_needs_kick_authority_and_removes_the_target() {
11587 let (bed, owner, member) = TestBed::new();
11588 bed.swap_to(&owner);
11589 let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
11590
11591 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
11593 let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
11594 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
11595 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
11596 let before = memberlist(&bed.relay, &community).await.unwrap();
11597 assert!(before.contains(&member.keys.public_key()), "the join lands first");
11598
11599 bed.swap_to(&member);
11601 let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
11602 assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
11603
11604 bed.swap_to(&owner);
11606 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
11607 let after = memberlist(&bed.relay, &community).await.unwrap();
11608 assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
11609 assert!(after.contains(&owner.keys.public_key()), "the owner remains");
11610 }
11611
11612 #[tokio::test]
11613 async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
11614 let (bed, owner, member) = TestBed::new();
11619 bed.swap_to(&owner);
11620 let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
11621 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11622 let (o, m) = (owner.keys.public_key(), member.keys.public_key());
11623 let join = |at: u64, id: u8| guestbook::GuestbookEvent {
11624 rumor_id: [id; 32],
11625 entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
11626 };
11627 let kick = |at: u64, id: u8| guestbook::GuestbookEvent {
11628 rumor_id: [id; 32],
11629 entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
11630 };
11631
11632 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
11634 assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
11635
11636 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
11638 assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
11639
11640 crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
11642 assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
11643
11644 assert!(
11647 !stored_memberlist(&community).unwrap().contains(&m),
11648 "the memberlist excludes an un-caught-up member — why it can't gate a kick"
11649 );
11650 }
11651
11652 fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
11657 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11658 crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
11659 for g in &roster.grants {
11660 let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
11661 let eid = super::super::derive::grant_locator(community.id(), &m);
11662 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
11663 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
11664 }
11665 }
11666
11667 async fn publish_grant_citing(
11669 relay: &MemoryRelay,
11670 community: &CommunityV2,
11671 signer: &Keys,
11672 member: &PublicKey,
11673 role_ids: Vec<String>,
11674 version: u64,
11675 citation: Option<&crate::community::edition::AuthorityCitation>,
11676 ) {
11677 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
11678 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
11679 let prev = head_hash_on_relay(relay, community, &eid).await;
11680 let grant = MemberGrant { member: member.to_hex(), role_ids };
11681 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
11682 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
11683 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
11684 relay.publish(&wrap, &community.relays).await.unwrap();
11685 }
11686
11687 #[tokio::test]
11688 async fn an_uncited_admin_edition_is_not_folded_but_a_cited_one_is() {
11689 let (bed, owner, admin) = TestBed::new();
11694 bed.swap_to(&owner);
11695 let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
11696 let admin_pk = admin.keys.public_key();
11697 let rid = "c3".repeat(32);
11698 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
11699 publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
11700
11701 let low_rid = "c4".repeat(32);
11705 let mut low = admin_role(&low_rid, Permissions::admin().0);
11706 low.position = 5;
11707 publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
11708
11709 let bystander = Keys::generate().public_key();
11710 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
11711 let view = fetch_authority(&bed.relay, &community).await;
11712 assert!(
11713 !view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
11714 "an uncited non-owner edition is not folded"
11715 );
11716 assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant folds");
11718
11719 let _ = follow_control(&bed.relay, &community, &SessionGuard::capture()).await;
11722 let entity_id = crate::community::v2::derive::grant_locator(community.id(), &admin_pk.to_bytes());
11723 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11724 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
11725 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
11726 let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
11727 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid], 2, Some(&cite)).await;
11728
11729 let view = fetch_authority(&bed.relay, &community).await;
11730 assert!(
11731 view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
11732 "the same edition WITH its synced citation folds"
11733 );
11734 }
11735
11736 #[tokio::test]
11737 async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
11738 let (bed, owner, member) = TestBed::new();
11743 bed.swap_to(&owner);
11744 let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
11745 let member_pk = member.keys.public_key();
11746 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11747
11748 crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
11750 crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
11751
11752 let join = guestbook::GuestbookEvent {
11754 rumor_id: [9u8; 32],
11755 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
11756 };
11757 assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
11758 assert!(
11759 !stored_memberlist(&community).unwrap().contains(&member_pk),
11760 "while banned, the fold keeps them out"
11761 );
11762
11763 crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
11765 assert!(
11766 stored_memberlist(&community).unwrap().contains(&member_pk),
11767 "after the unban the raced Join makes them a member again"
11768 );
11769 }
11770
11771 #[tokio::test]
11772 async fn a_stale_root_admin_write_is_refused_not_misdirected() {
11773 let (bed, owner, member) = TestBed::new();
11779 bed.swap_to(&owner);
11780 let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
11781 let member_pk = member.keys.public_key();
11782
11783 set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
11784 let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
11785
11786 let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
11788 assert!(err.contains("re-founded"), "unban: {err}");
11789 let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
11791 assert!(err.contains("re-founded"), "invite: {err}");
11792 let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
11794 assert!(err.contains("re-founded"), "kick: {err}");
11795
11796 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11798 set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
11799 let view = fetch_authority(&bed.relay, &fresh).await;
11800 assert!(view.banned.is_empty(), "the retried unban actually unbans");
11801 }
11802
11803 #[tokio::test]
11804 async fn an_uncited_kick_from_an_admin_is_not_honored() {
11805 let (bed, owner, member) = TestBed::new();
11810 bed.swap_to(&owner);
11811 let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
11812 let admin = Keys::generate();
11813 let member_pk = member.keys.public_key();
11814 grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
11815
11816 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11817 let view = fetch_authority(&bed.relay, &community).await;
11818 crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
11819
11820 let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
11821 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
11822 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
11823 let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
11824
11825 let joined = guestbook::GuestbookEvent {
11826 rumor_id: [1u8; 32],
11827 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
11828 };
11829 let kick = |citation, id: u8, at| guestbook::GuestbookEvent {
11830 rumor_id: [id; 32],
11831 entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
11832 };
11833 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
11834 let empty_bans = std::collections::BTreeSet::new();
11835 let empty_marks = std::collections::BTreeMap::new();
11836 let fold = |evs: &[guestbook::GuestbookEvent]| {
11837 fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
11838 };
11839
11840 assert!(
11841 fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
11842 "an uncited kick from an admin is not honored"
11843 );
11844 assert!(
11845 !fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
11846 "the same kick WITH its synced citation removes them"
11847 );
11848 }
11849
11850 #[tokio::test]
11851 async fn kicking_an_admin_strips_their_roles_first() {
11852 let (bed, owner, member) = TestBed::new();
11856 bed.swap_to(&owner);
11857 let community = create_community(&bed.relay, "Compose", 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 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
11864
11865 kick_member(&bed.relay, &community, &member_pk).await.unwrap();
11866
11867 let view = fetch_authority(&bed.relay, &community).await;
11868 assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
11869 assert!(
11870 !view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
11871 "a kicked admin holds no bit"
11872 );
11873 assert!(
11874 !memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
11875 "and the directive still removed them"
11876 );
11877 }
11878
11879 #[tokio::test]
11880 async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
11881 let (bed, owner, member) = TestBed::new();
11882 bed.swap_to(&owner);
11883 let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
11884 let member_pk = member.keys.public_key();
11885 let member_hex = member_pk.to_hex();
11886 let owner_hex = owner.keys.public_key().to_hex();
11887
11888 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
11889 let view = fetch_authority(&bed.relay, &community).await;
11890 assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
11891 assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
11892
11893 let second = Keys::generate().public_key();
11896 grant_admin(&bed.relay, &community, &second).await.unwrap();
11897 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
11898 let view = fetch_authority(&bed.relay, &community).await;
11899 assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
11900 assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
11901 let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
11902 assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
11903
11904 revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
11906 let view = fetch_authority(&bed.relay, &community).await;
11907 assert!(!view.roles.is_admin(&member_hex), "revoked");
11908 assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
11909 assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
11910 }
11911
11912 #[tokio::test]
11913 async fn follow_control_persists_the_roster_for_sync_local_reads() {
11914 let (bed, owner, member) = TestBed::new();
11915 bed.swap_to(&owner);
11916 let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
11917 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11918 let member_hex = member.keys.public_key().to_hex();
11919 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
11920
11921 let session = crate::state::SessionGuard::capture();
11923 follow_control(&bed.relay, &community, &session).await.unwrap();
11924 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11925 assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
11926
11927 let withholding = MemoryRelay::new();
11930 let _ = follow_control(&withholding, &community, &session).await;
11931 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11932 assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
11933
11934 revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
11936 follow_control(&bed.relay, &community, &session).await.unwrap();
11937 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11938 assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
11939 }
11940
11941 #[tokio::test]
11942 async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
11943 let (bed, owner, member) = TestBed::new();
11944 bed.swap_to(&owner);
11945 let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
11946
11947 bed.swap_to(&member);
11948 let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
11949 assert!(err.contains("owner"), "refused before any publish: {err}");
11950
11951 bed.swap_to(&owner);
11954 let view = fetch_authority(&bed.relay, &community).await;
11955 assert!(view.roles.roles.is_empty(), "no role edition landed");
11956 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
11957 let view = fetch_authority(&bed.relay, &community).await;
11958 assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
11959 }
11960
11961 #[tokio::test]
11962 async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
11963 let (bed, owner, member) = TestBed::new();
11964 bed.swap_to(&owner);
11965 let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
11966 let member_pk = member.keys.public_key();
11967
11968 let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
11971 set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
11972 grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
11973
11974 let withholding = MemoryRelay::new();
11977 let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
11978 assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
11979
11980 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
11982 let view = fetch_authority(&bed.relay, &community).await;
11983 let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
11984 assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
11985 assert!(grant.role_ids.contains(&mod_rid));
11986 }
11987
11988 #[tokio::test]
11989 async fn fetch_authority_reflects_a_granted_admin() {
11990 let (bed, owner, member) = TestBed::new();
11991 bed.swap_to(&owner);
11992 let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
11993 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
11994 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11995 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
11996
11997 let view = fetch_authority(&bed.relay, &community).await;
11998 let member_hex = member.keys.public_key().to_hex();
11999 assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
12000 assert!(
12001 view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
12002 "an ADMIN_ALL grant carries KICK"
12003 );
12004 assert!(view.banned.is_empty());
12005 }
12006}